diff --git a/startup/accounts.js b/api/accounts.ts
similarity index 100%
rename from startup/accounts.js
rename to api/accounts.ts
diff --git a/api/games/_logic/Bet.ts b/api/games/_logic/Bet.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3eda988863f4831e3c0ef0726d2e2316986a22b0
--- /dev/null
+++ b/api/games/_logic/Bet.ts
@@ -0,0 +1,5 @@
+export const enum Bet {
+  NONE = 0,
+  TICHU = 100,
+  GRAND = 200
+}
diff --git a/api/games/_logic/Card.ts b/api/games/_logic/Card.ts
new file mode 100644
index 0000000000000000000000000000000000000000..24c92fb5425b19a5414b061686b8d59425cb0daf
--- /dev/null
+++ b/api/games/_logic/Card.ts
@@ -0,0 +1,95 @@
+export const enum Color {
+  DOG,
+  MAHJONG,
+  PHOENIX,
+  BLUE,
+  GREEN,
+  RED,
+  BLACK,
+  DRAGON,
+  MAX_COLOR
+}
+
+export class CardData {
+  color: Color
+  value: number
+}
+
+export class Card {
+  color: Color
+  value: number
+
+  constructor (color: Color, value: number) {
+    this.color = color
+    this.value = value
+  }
+
+  isPhoenix (): boolean {
+    return this.color === Color.PHOENIX
+  }
+
+  /**
+   * The number of points this Card is worth.
+   */
+  points (): number {
+    if (this.color === Color.PHOENIX) return -25
+    else if (this.color === Color.DRAGON) return 25
+    else if (this.value === 5) return 5
+    else if (this.value === 10 || this.value === 13) return 10
+    else return 0
+  }
+
+  static get DOG (): Card {
+    return new Card(Color.DOG, 0)
+  }
+
+  static get MAHJONG (): Card {
+    return new Card(Color.MAHJONG, 1)
+  }
+
+  static get PHOENIX (): Card {
+    return new Card(Color.PHOENIX, 0)
+  }
+
+  static get DRAGON (): Card {
+    return new Card(Color.DRAGON, 15)
+  }
+
+  static fromObject (obj: CardData): Card {
+    return new Card(obj.color, obj.value)
+  }
+
+  toObject (): CardData {
+    return Object.assign({}, this)
+  }
+}
+
+/**
+ * Compare two cards. Used for display.
+ *
+ * @return {number} Negative when c1 is before c2,
+ *                  null when they're the same,
+ *                  positive when c2 is before c1.
+ */
+export function compareCards (c1: Card, c2: Card): number {
+  if (c1.color < 2 && c2.color < 2) return c2.color - c1.color
+  else if (c1.color < 2) return 1
+  else if (c2.color < 2) return -1
+  else if (c1.color === Color.DRAGON && c2.color === Color.DRAGON) return 0
+  else if (c1.color === Color.DRAGON) return -1
+  else return c2.value - c1.value + (c2.color - c1.color) / Color.MAX_COLOR
+}
+
+/**
+ * Whether two cards are the same.
+ */
+export function cardsEqual (c1: Card, c2: Card): boolean {
+  return (c1.color === Color.PHOENIX && c2.color === Color.PHOENIX) || (c1.color === c2.color && c1.value === c2.value)
+}
+
+/**
+ * Whether two cards are of the same value.
+ */
+export function cardsValueEqual (c1: Card, c2: Card): boolean {
+  return (c1.color === Color.PHOENIX && c2.color === Color.PHOENIX) || c1.value === c2.value
+}
diff --git a/api/games/_logic/CardSet.ts b/api/games/_logic/CardSet.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1c90e10fda8b2749bdcb94f1856d954f9ec2be4f
--- /dev/null
+++ b/api/games/_logic/CardSet.ts
@@ -0,0 +1,86 @@
+import { SortedArraySet } from 'collections/sorted-array-set'
+import { Card, CardData, cardsEqual, compareCards } from './Card'
+import { Trick } from './Trick'
+
+export class CardSet extends SortedArraySet<Card> {
+  /**
+   * The trick corresponding to this CardSet, or null if it has not be computed yet.
+   * It is used to not compute the trick every time `CardSet.trick` is called.
+   */
+  private _cached_trick: Trick | null
+
+  constructor (cards: Card[] = []) {
+    super(cards, cardsEqual, compareCards, () => Card.DOG)
+    this._cached_trick = null
+  }
+
+  // SET METHODS
+
+  clear (): void {
+    super.clear()
+    this._cached_trick = null
+  }
+
+  add (card: Card): boolean {
+    this._cached_trick = null
+    return super.add(card)
+  }
+
+  push (...cards: Card[]): void {
+    this._cached_trick = null
+    return super.push(...cards)
+  }
+
+  remove (card: Card) {
+    this._cached_trick = null
+    return super.remove(card)
+  }
+
+  // CARDS METHODS
+
+  /**
+   * Whether this CardSet can be played over the `other` CardSet.
+   */
+  canBePlayedOver (other: CardSet): boolean {
+    if (other.length === 0 || (this.trick().isBomb() && !other.trick().isBomb())) {
+      // Rien n'a été joué.
+      // ou
+      // On a joué une bombe sur une combinaison normale.
+      return true
+    } else if ((!other.trick().isBomb() && this.length !== other.length) || (!this.trick().isBomb() && other.trick().isBomb())) {
+      // On joue sur une combinaison normale, et notre combinaison n'a pas le même nombre de cartes que l'autre.
+      // ou
+      // On joue une combinaison normale sur une bombe.
+      return false
+    } else if (this.trick().length === 1 && this.has(Card.PHOENIX)) {
+      return !other.has(Card.DRAGON)
+    } else {
+      return this.trick().value - other.trick().value > 0
+    }
+  }
+
+  /**
+   * The number of points this CardSet is worth.
+   */
+  points (): number {
+    return this.reduce((p, c) => p + c.points(), 0)
+  }
+
+  /**
+   * The trick corresponding to this CardSet.
+   */
+  trick (): Trick {
+    if (this._cached_trick == null) {
+      this._cached_trick = Trick.fromSortedArray(this.toArray())
+    }
+    return this._cached_trick
+  }
+
+  static fromObject (obj: CardData[]): CardSet {
+    return new CardSet(obj.map(Card.fromObject))
+  }
+
+  toObject (): CardData[] {
+    return this.toArray().map(c => c.toObject())
+  }
+}
diff --git a/api/games/_logic/GamePov.ts b/api/games/_logic/GamePov.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0dd7c1134832640b9cf0047787c6de130ba19cdf
--- /dev/null
+++ b/api/games/_logic/GamePov.ts
@@ -0,0 +1,76 @@
+import { GameState, GameSystem, Place } from './GameSystem'
+import { Card } from './Card'
+
+/** 0 (west), 1 (north), 2 (east) or null (south) */
+export declare type RelativePlace = null | 0 | 1 | 2
+
+/**
+ * A game from the point of view of a player placed at a given place.
+ */
+export class GamePov {
+  /**
+   * Underlying game.
+   */
+  private _game: GameSystem
+
+  /**
+   * The place of the player.
+   */
+  private readonly _place: Place
+
+  constructor (game: GameSystem, place: Place) {
+    this._game = game
+    this._place = place
+  }
+
+  get hand (): Card[] {
+    return this._game._players[this._place].hand.toArray()
+  }
+
+  get givingCards (): boolean {
+    return this._game._state === GameState.GIVING_CARDS
+  }
+
+  get givingDragon (): boolean {
+    return this._game._state === GameState.GIVING_DRAGON && this._game._playing === this._place
+  }
+
+  isPlaying (relativePlace: RelativePlace): boolean {
+    let place = this._absolutePlace(relativePlace)
+    return this._game._playing === ((this._place + place) % 4)
+  }
+
+  hasLead (relativePlace: RelativePlace): boolean {
+    let place = this._absolutePlace(relativePlace)
+    return this._game._lead === ((this._place + place) % 4)
+  }
+
+  get discard (): Card[] | null {
+    if (this._game._discard.length > 0) {
+      return this._game._discard[this._game._discard.length - 1].toArray()
+    } else {
+      return null
+    }
+  }
+
+  bet (relativePlace: RelativePlace): number {
+    let place = this._absolutePlace(relativePlace)
+    return this._game._players[place].bet
+  }
+
+  numCards (relativePlace: RelativePlace): number {
+    let place = this._absolutePlace(relativePlace)
+    return this._game._players[place].hand.length
+  }
+
+  /**
+   * From a place relative to this._place returns the absolute place in the game.
+   */
+  private _absolutePlace (rel: RelativePlace): Place {
+    if (rel == null) {
+      return this._place
+    } else {
+      return <Place>((this._place - rel + 3) % 4)
+    }
+  }
+}
diff --git a/api/games/_logic/GameSystem.ts b/api/games/_logic/GameSystem.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5931d8d9e6e5ee57e07c5ec4d4868299afdbca0d
--- /dev/null
+++ b/api/games/_logic/GameSystem.ts
@@ -0,0 +1,407 @@
+import { Card, CardData } from './Card'
+import { Player, PlayerData } from './Player'
+import { CardSet } from './CardSet'
+import { fullDeck, shuffle, splitChunk } from './deck'
+import { Bet } from './Bet'
+
+export declare type Place = 0 | 1 | 2 | 3
+
+export const NUM_PLAYERS = 4
+//export const NUM_CARDS = 56
+
+export const enum GameState {
+  GIVING_CARDS = 1,
+  PLAYING,
+  GIVING_DRAGON
+}
+
+function initialGameState (): GameState {
+  return GameState.GIVING_CARDS
+}
+
+export class GameSystemData {
+  _players: PlayerData[]
+  _points02: number[]
+  _points13: number[]
+  _discard: CardData[][]
+  _state: GameState
+  _first: Place | -1
+  _playing: Place | -1
+  _lead: Place | -1
+  _maxPoints: number
+}
+
+export class GameSystem {
+  /**
+   * The array of players.
+   * The (n+1)th player is at the right of the nth player, modulo the number of players.
+   */
+  _players: Player[]
+
+  /**
+   * The number of points the team formed by the 0th and the 2th player has earned during each round.
+   */
+  _points02: number[]
+
+  /**
+   * The number of points the team formed by the 1th and the 3th player has earned during each round.
+   */
+  _points13: number[]
+
+  /**
+   * The discard, as a stack of the played tricks.
+   */
+  _discard: CardSet[]
+
+  /**
+   * The state of the game.
+   */
+  _state: GameState
+
+  /**
+   * The first player that has finished the round, or -1 if there is none.
+   */
+  _first: Place | -1
+
+  /**
+   * The player that has to play, or -1 if there is none (GameState != PLAYING)
+   */
+  _playing: Place | -1
+
+  /**
+   * The player that will get the discard if the others skip turns, or -1 if there is none.
+   */
+  _lead: Place | -1
+
+  /**
+   * The point cap to reach for the game to end.
+   */
+  _maxPoints: number
+
+  constructor () {
+    this._players = []
+    this._points02 = []
+    this._points13 = []
+    this._discard = []
+    this._state = initialGameState()
+    this._first = -1
+    this._playing = -1
+    this._lead = -1
+    this._maxPoints = 100
+
+    for (let i = 0; i < NUM_PLAYERS; i++) {
+      this._players.push(new Player())
+    }
+  }
+
+  ///////////
+  // UTILS //
+  ///////////
+
+  /**
+   * Whether the game is over.
+   *
+   * @return {number}  -1 if the game is not over
+   *                    0 if the team 02 wins
+   *                    1 if the team 13 wins
+   */
+  winner (): -1 | 0 | 1 {
+    let points02 = this._points02.reduce((s, p) => s + p)
+    let points13 = this._points13.reduce((s, p) => s + p)
+
+    if ((points02 >= this._maxPoints || points13 >= this._maxPoints) && points02 !== points13) {
+      return points02 > points13 ? 0 : 1
+    } else {
+      return -1
+    }
+  }
+
+  /**
+   * Counts the points of each player and add them to the points of the round.
+   * A new round must start after a call to this method.
+   */
+  private _countPoints (): void {
+    let points02 = 0
+    let points13 = 0
+    let unfinished = []
+
+    for (let i = 0; i < NUM_PLAYERS; i++) {
+      if (this._players[i].hand.length > 0) {
+        unfinished.push(i)
+      }
+    }
+
+    if (unfinished.length === 1) {
+      let last = unfinished[0]
+
+      if (last % 2 === 0) {
+        // Last player is in team 02
+        points13 += this._players[last].hand.points()
+      } else {
+        points02 += this._players[last].hand.points()
+      }
+
+      if (this._first % 2 === 0) {
+        // First player is in team 02
+        points02 += this._players[last].tricks.points()
+      } else {
+        points13 += this._players[last].tricks.points()
+      }
+
+      this._players[last].tricks.clear()
+      points02 += this._players[0].tricks.points() + this._players[2].tricks.points()
+      points13 += this._players[1].tricks.points() + this._players[3].tricks.points()
+    } else if (this._players[0].hand.length > 0) {
+      // One-Two. Team 13 wins
+      points02 = 0
+      points13 = 200
+    } else {
+      // One-Two. Team 02 wins
+      points02 = 200
+      points13 = 0
+    }
+
+    for (let player = 0; player < this._players.length; player++) {
+      let betPoints = (player === this._first ? 1 : -1) * this._players[player].bet
+      if (player % 2 === 0) {
+        points02 += betPoints
+      } else {
+        points13 += betPoints
+      }
+    }
+
+    this._points02.push(points02)
+    this._points13.push(points13)
+  }
+
+  private _hasRoundFinished (): boolean {
+    return this._players.filter(p => p.hand.length > 0).length === 1 ||
+      (this._players[0].hand.length === 0 && this._players[2].hand.length === 0) ||
+      (this._players[1].hand.length === 0 && this._players[3].hand.length === 0)
+  }
+
+  /**
+   * Whether the given player has every given card in hand.
+   */
+  _playerHasCards (player: Place, cards: Card[]): boolean {
+    return cards.every(c => this._players[player].hand.has(c))
+  }
+
+  newRound (): void {
+    this._discard = []
+    this._state = initialGameState()
+    this._first = -1
+    this._playing = -1
+    this._lead = -1
+
+    let deck = fullDeck()
+    shuffle(deck)
+    let hands = splitChunk(deck, NUM_PLAYERS)
+    for (let i = 0; i < this._players.length; i++) {
+      this._players[i].newRound(hands[i])
+    }
+  }
+
+  private _startPlaying (): void {
+    this._state = GameState.PLAYING
+    this._playing = <Place>this._players.findIndex(p => p.hand.has(Card.MAHJONG))
+    this._lead = this._playing
+
+    for (let player = 0; player < NUM_PLAYERS; player++) {
+      for (let i = 0; i < 3; i++) {
+        let givenCard = this._players[player].cardsGiven[i]
+        this._players[(player - i + 3) % 4].hand.add(givenCard)
+      }
+    }
+  }
+
+  get points02 (): number[] {
+    return this._points02
+  }
+
+  get points13 (): number[] {
+    return this._points13
+  }
+
+  /////////////
+  // ACTIONS //
+  /////////////
+
+  /** A player give three cards. */
+  giveCards (player: Place, cards: Card[]): boolean {
+    if (this._state !== GameState.GIVING_CARDS || !this._playerHasCards(player, cards) || !this._players[player].hasNextCards) {
+      return false
+    }
+
+    cards.forEach(c => this._players[player].hand.remove(c))
+    this._players[player].cardsGiven = cards
+
+    if (this._players.every(p => p.cardsGiven.length > 0)) {
+      this._startPlaying()
+    }
+
+    return true
+  }
+
+  /** A player calls Tichu or Grand Tichu. */
+  makeCall (player: Place, bet: number): boolean {
+    if (this._players[player].bet !== Bet.NONE) {
+      return false
+    }
+
+    let res = true
+    if (bet === Bet.TICHU && (this._players[player].hand.length === 14 || (this._players[player].hand.length === 11 && this._state === GameState.GIVING_CARDS))) {
+      this._players[player].bet = Bet.TICHU
+    } else if (bet === Bet.GRAND && this._state === GameState.GIVING_CARDS && !this._players[player].hasNextCards) {
+      this._players[player].bet = Bet.GRAND
+      this._players[player].hasNextCards = true
+    } else {
+      res = false
+    }
+
+    return res
+  }
+
+  /** A player asks for the next cards (hasn't called grand tichu). */
+  nextCards (player: Place): boolean {
+    if (this._state !== GameState.GIVING_CARDS || this._players[player].hasNextCards) {
+      return false
+    }
+
+    this._players[player].hasNextCards = true
+
+    return true
+  }
+
+  /** A player plays cards. */
+  playCards (player: Place, cards: Card[]): boolean {
+    if (!this._playerHasCards(player, cards) || this._state !== GameState.PLAYING) {
+      return false
+    }
+
+    let played = new CardSet(cards)
+
+    if (this._playing !== player && !(played.trick().isBomb() && this._discard.length > 0)) {
+      // It's not the player's turn, and the player is not playing a valid bomb (a bomb over a non-empty discard).
+      return false
+    }
+
+    if (!played.canBePlayedOver(this._discard[this._discard.length - 1])) {
+      return false
+    }
+
+    if (played.length === 1 && played.has(Card.PHOENIX)) {
+      let card = Card.PHOENIX
+      card.value = this._discard[this._discard.length - 1].toArray()[0].value + 0.5
+      played = new CardSet([card])
+    }
+
+    // Play the card and update this._playing according to the card played.
+    if (played.length === 1 && played.has(Card.DOG)) {
+      this._players[player].tricks.add(Card.DOG)
+      this._playing = <Place>((player + 2) % 4)
+    } else {
+      this._discard.push(played)
+      this._playing = <Place>((player + 1) % 4)
+    }
+
+    // Update this._lead and remove the cards from the players' hand.
+    this._lead = player
+    cards.forEach(c => this._players[player].hand.remove(c))
+
+    // Check if the player has finished, then if the game has finished.
+    if (this._players[player].hand.length === 0) {
+      if (this._first < 0) {
+        this._first = player
+      } else if (this._hasRoundFinished()) {
+        this._countPoints()
+        if (this.winner() < 0) {
+          this.newRound()
+        }
+        return true
+      }
+    }
+
+    // Update this._playing according to players that has already finished.
+    while (this._players[this._playing].hand.length === 0) {
+      this._playing = <Place>((this._playing + 1) % 4)
+    }
+
+    return true
+  }
+
+  /** A player skips a turn. */
+  skipTurn (player: Place): boolean {
+    if (this._state !== GameState.PLAYING || this._playing !== player || this._discard.length === 0) {
+      return false
+    }
+
+    // Update this._playing until it finds a player that has cards or the leader.
+    do {
+      this._playing = <Place>((this._playing + 1) % 4)
+    } while (this._players[this._playing].hand.length === 0 && this._playing !== this._lead)
+
+    if (this._playing === this._lead) {
+      // The leader is found and can take the cards in the discard, or give the dragon.
+      if (this._discard[this._discard.length - 1].has(Card.DRAGON)) {
+        this._state = GameState.GIVING_DRAGON
+      } else {
+        this._discard.forEach(d => this._players[this._lead].tricks.push(...d.toArray()))
+        this._discard = []
+        // The leader might have no card left.
+        while (this._players[this._playing].hand.length === 0) {
+          this._playing = <Place>((this._playing + 1) % 4)
+        }
+      }
+    }
+
+    return true
+  }
+
+  /** A player gives the dragon. */
+  giveDragon (player: Place, to: 0 | 1): boolean {
+    if (this._state !== GameState.GIVING_DRAGON || this._lead !== player) {
+      return false
+    }
+
+    this._state = GameState.PLAYING
+    let absoluteTo = (player + (to === 0 ? 1 : 3)) % 4
+    this._discard.forEach(d => this._players[absoluteTo].tricks.push(...d.toArray()))
+    this._discard = []
+    while (this._players[this._playing].hand.length === 0) {
+      this._playing = <Place>((this._playing + 1) % 4)
+    }
+
+    return true
+  }
+
+  static fromObject (obj: GameSystemData): GameSystem {
+    let sys: GameSystem = Object.assign(new GameSystem(), obj)
+
+    for (let i = 0; i < sys._players.length; i++) {
+      sys._players[i] = Player.fromObject(obj._players[i])
+    }
+
+    for (let i = 0; i < sys._discard.length; i++) {
+      sys._discard[i] = CardSet.fromObject(obj._discard[i])
+    }
+
+    return sys
+  }
+
+  toObject (): GameSystemData {
+    let obj = new GameSystemData()
+
+    obj._players = this._players.map(p => p.toObject())
+    obj._points02 = this._points02
+    obj._points13 = this._points13
+    obj._discard = this._discard.map(t => t.toObject())
+    obj._state = this._state
+    obj._first = this._first
+    obj._playing = this._playing
+    obj._lead = this._lead
+    obj._maxPoints = this._maxPoints
+
+    return obj
+  }
+}
\ No newline at end of file
diff --git a/api/games/_logic/Player.ts b/api/games/_logic/Player.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0d351e092191d49121f057ddb8149087bfee7c8f
--- /dev/null
+++ b/api/games/_logic/Player.ts
@@ -0,0 +1,58 @@
+import { Card, CardData } from './Card'
+import { CardSet } from './CardSet'
+import { Bet } from './Bet'
+
+export class PlayerData {
+  hand: CardData[]
+  bet: Bet
+  tricks: CardData[]
+  cardsGiven: CardData[]
+  hasNextCards: boolean
+}
+
+export class Player {
+  hand: CardSet
+  bet: Bet
+  tricks: CardSet
+  cardsGiven: Card[]
+  hasNextCards: boolean
+
+  constructor () {
+    this.hand = new CardSet()
+    this.bet = Bet.NONE
+    this.tricks = new CardSet()
+    this.cardsGiven = []
+    this.hasNextCards = false
+  }
+
+  newRound (cards: Card[]) {
+    this.hand.clear()
+    this.hand.push(...cards)
+    this.bet = Bet.NONE
+    this.tricks.clear()
+    this.cardsGiven = []
+    this.hasNextCards = false
+  }
+
+  static fromObject (obj: PlayerData): Player {
+    let player: Player = Object.assign(new Player(), obj)
+
+    player.hand = CardSet.fromObject(obj.hand)
+    player.tricks = CardSet.fromObject(obj.tricks)
+    player.cardsGiven = obj.cardsGiven.map(Card.fromObject)
+
+    return player
+  }
+
+  toObject (): PlayerData {
+    let obj = new PlayerData()
+
+    obj.bet = this.bet
+    obj.hasNextCards = this.hasNextCards
+    obj.hand = this.hand.toObject()
+    obj.tricks = this.tricks.toObject()
+    obj.cardsGiven = this.cardsGiven.map(c => c.toObject())
+
+    return obj
+  }
+}
\ No newline at end of file
diff --git a/api/games/_logic/Trick.ts b/api/games/_logic/Trick.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9df25ed97e8a7204f0515134465bcb9acb337020
--- /dev/null
+++ b/api/games/_logic/Trick.ts
@@ -0,0 +1,144 @@
+import { Card } from './Card'
+
+export enum TrickKind {
+  INVALID,
+  SIMPLE,
+  DOUBLE,
+  TRIPLE,
+  FULL_HOUSE,
+  SUIT,
+  PAIR_SUIT,
+  BOMB
+}
+
+export class Trick {
+  kind: TrickKind
+  _value: number
+  length: number
+
+  /**
+   * @throws {Error} when length is not valid for the given `kind`.
+   */
+  private constructor (kind: TrickKind, value: number, length: number = 0) {
+    this.kind = kind
+    this._value = value
+    this.length = length
+
+    switch (kind) {
+      case TrickKind.INVALID:
+        break
+      case TrickKind.SIMPLE:
+        this.length = 1
+        break
+      case TrickKind.DOUBLE:
+        this.length = 2
+        break
+      case TrickKind.TRIPLE:
+        this.length = 3
+        break
+      case TrickKind.FULL_HOUSE:
+        this.length = 5
+        break
+      default:
+        if (length < 4 || 14 < length) {
+          throw new Error(`Trick constructor invalid arguments, kind: ${kind} length: ${length}`)
+        }
+        this.length = length
+    }
+  }
+
+  isValid (): boolean {
+    return this.kind !== TrickKind.INVALID
+  }
+
+  isBomb (): boolean {
+    return this.kind === TrickKind.BOMB
+  }
+
+  get value (): number {
+    if (this.kind === TrickKind.BOMB) {
+      return this._value + 100 * this.length
+    } else {
+      return this._value
+    }
+  }
+
+  // Constructors
+
+  static get INVALID (): Trick {
+    return new Trick(TrickKind.INVALID, -1)
+  }
+
+  static SIMPLE (value: number): Trick {
+    return new Trick(TrickKind.SIMPLE, value)
+  }
+
+  static DOUBLE (value: number): Trick {
+    return new Trick(TrickKind.DOUBLE, value)
+  }
+
+  static TRIPLE (value: number): Trick {
+    return new Trick(TrickKind.TRIPLE, value)
+  }
+
+  static FULL_HOUSE (value: number): Trick {
+    return new Trick(TrickKind.FULL_HOUSE, value)
+  }
+
+  static SUIT (ncards: number, value: number): Trick {
+    return new Trick(TrickKind.SUIT, value, ncards)
+  }
+
+  static PAIR_SUIT (nCards: number, value: number): Trick {
+    return new Trick(TrickKind.PAIR_SUIT, value, nCards)
+  }
+
+  static BOMB (nCards: number, value: number): Trick {
+    return new Trick(TrickKind.BOMB, value, nCards)
+  }
+
+  /**
+   * Find out the kind of trick from a sorted array of cards.
+   * The cards must be sorted from the higgest value to the lowest.
+   */
+  static fromSortedArray (cards: Card[]): Trick {
+    if (cards.length === 0) return Trick.INVALID
+    switch (cards.length) {
+      case 1:
+        let card = cards[0]
+        return Trick.SIMPLE(card.isPhoenix() ? -1 : card.value)
+      case 2:
+        return (cards[0].value === cards[1].value) ? Trick.DOUBLE(cards[0].value) : Trick.INVALID
+      case 3:
+        return (cards[0].value === cards[1].value && cards[0].value === cards[2].value)
+          ? Trick.TRIPLE(cards[0].value)
+          : Trick.INVALID
+      case 4:
+        if (cards[0].value === cards[1].value && cards[2].value === cards[3].value) {
+          if (cards[1].value === cards[2].value && cards.every(c => !c.isPhoenix())) {
+            return Trick.BOMB(4, cards[1].value)
+          } else if (cards[1].value - 1 === cards[2].value) {
+            return Trick.PAIR_SUIT(4, cards[0].value)
+          }
+        }
+        return Trick.INVALID
+      case 5:
+        if (cards[0].value === cards[1].value && cards[0].value === cards[2].value && cards[3].value === cards[4].value) {
+          return Trick.FULL_HOUSE(cards[0].value)
+        } else if (cards[0].value === cards[1].value && cards[2].value === cards[3].value && cards[2].value === cards[4].value) {
+          return Trick.FULL_HOUSE(cards[2].value)
+        }
+      /* FALLTHROUGH */
+      default:
+        if (cards.length % 2 === 0 && cards.every((c, i, cards) => cards[0].value - c.value === Math.floor(i / 2))) {
+          return Trick.PAIR_SUIT(cards.length, cards[0].value)
+        } else if (cards.every((c, i, cards) => cards[0].value - c.value === i)) {
+          return cards.every(c => c.color === cards[0].color && !c.isPhoenix())
+            ? Trick.BOMB(cards.length, cards[0].value)
+            : Trick.SUIT(cards.length, cards[0].value)
+        } else {
+          return Trick.INVALID
+        }
+    }
+  }
+}
diff --git a/api/games/_logic/deck.ts b/api/games/_logic/deck.ts
new file mode 100644
index 0000000000000000000000000000000000000000..67a383f565c83943cb5af2e41bce48a231fa1d94
--- /dev/null
+++ b/api/games/_logic/deck.ts
@@ -0,0 +1,36 @@
+import { Card, Color } from './Card'
+
+/** All the not-special cards. */
+export function partialDeck (): Card[] {
+  let deck: Card[] = []
+  for (let color of [Color.BLUE, Color.GREEN, Color.RED, Color.BLACK]) {
+    for (let value = 2; value < 15; value++) {
+      deck.push(new Card(color, value))
+    }
+  }
+  return deck
+}
+
+/** The deck of all cards. */
+export function fullDeck (): Card[] {
+  return [Card.DOG, Card.MAHJONG, Card.PHOENIX, Card.DRAGON].concat(partialDeck())
+}
+
+/** Shuffles an array. */
+export function shuffle<T> (arr: T[]): T[] {
+  for (let i = arr.length - 1; i > 0; i--) {
+    const j = Math.floor(Math.random() * (i + 1));
+    [arr[i], arr[j]] = [arr[j], arr[i]]
+  }
+  return arr
+}
+
+/** Split an array into n chunks of equal length. */
+export function splitChunk<T> (arr: T[], n: number): T[][] {
+  let chunkLength = Math.floor(arr.length / n)
+  let res: T[][] = []
+  for (let i = 0; i < n; i++) {
+    res.push(arr.slice(i * chunkLength, (i + 1) * chunkLength))
+  }
+  return res
+}
diff --git a/api/games/cards.js b/api/games/cards.js
deleted file mode 100644
index 972d49e9170014a0502604fc52ab40ad0c6f1703..0000000000000000000000000000000000000000
--- a/api/games/cards.js
+++ /dev/null
@@ -1,517 +0,0 @@
-import SortedArraySet from 'collections/sorted-set'
-
-export const Color = {
-  DOG: 0,
-  MAHJONG: 1,
-  PHOENIX: 2,
-  BLUE: 3,
-  GREEN: 4,
-  RED: 5,
-  BLACK: 6,
-  DRAGON: 7,
-  MAX_COLOR: 8
-}
-
-const TrickKind = {
-  INVALID: 0,
-  SIMPLE: 1,
-  DOUBLE: 2,
-  TRIPLE: 3,
-  FULL_HOUSE: 4,
-  SUIT: 5,
-  PAIR_SUIT: 6,
-  BOMB: 7
-}
-
-/**
- * Compare two cards. Used for display.
- *
- * @param {Card} c1
- * @param {Card} c2
- * @return {number} Negative when c1 is before c2,
- *                  null when they're the same,
- *                  positive when c2 is before c1.
- */
-export function compareCards (c1, c2) {
-  if (c1.color < 2 && c2.color < 2) return c2.color - c1.color
-  else if (c1.color < 2) return 1
-  else if (c2.color < 2) return -1
-  else if (c1.color === Color.DRAGON && c2.color === Color.DRAGON) return 0
-  else if (c1.color === Color.DRAGON) return -1
-  else return c2.value - c1.value + (c2.color - c1.color) / Color.MAX_COLOR
-}
-
-/**
- * Whether two cards are the same.
- *
- * @param {Card} c1
- * @param {Card} c2
- * @return {boolean}
- */
-export function cardsEqual (c1, c2) {
-  return (c1.color === Color.PHOENIX && c2.color === Color.PHOENIX) || (c1.color === c2.color && c1.value === c2.value)
-}
-
-/**
- * Whether two cards are of the same value.
- *
- * @param {Card} c1
- * @param {Card} c2
- * @return {boolean}
- */
-export function cardsValueEqual (c1, c2) {
-  return (c1.color === Color.PHOENIX && c2.color === Color.PHOENIX) || c1.value === c2.value
-}
-
-export class Card {
-
-  /**
-   * Default constructor.
-   *
-   * @param {number} color
-   * @param {number} value
-   */
-  constructor (color, value) {
-
-    /**
-     * The color of the card.
-     *
-     * @type {number}
-     */
-    this.color = color
-
-    /**
-     * The value of the card.
-     *
-     * @type {number}
-     */
-    this.value = value
-  }
-
-  isPhoenix () {
-    return this.color === Color.PHOENIX
-  }
-
-  /**
-   * The number of points this Card is worth.
-   *
-   * @return {number}
-   */
-  points () {
-    if (this.color === Color.PHOENIX) return -25
-    else if (this.color === Color.DRAGON) return 25
-    else if (this.value === 5) return 5
-    else if (this.value === 10 || this.value === 13) return 10
-    else return 0
-  }
-
-  /**
-   * The name of the card.
-   *
-   * @return {string}
-   */
-  name () {
-    switch (this.color) {
-      case Color.DOG: return 'dog'
-      case Color.MAHJONG: return 'mahjong'
-      case Color.PHOENIX: return 'phoenix'
-      case Color.BLUE: return this.value + ' of pagoda'
-      case Color.GREEN: return this.value + ' of jade'
-      case Color.RED: return this.value + ' of star'
-      case Color.BLACK: return this.value + ' of sword'
-      case Color.DRAGON: return 'dragon'
-      default: return 'INVALID CARD'
-    }
-  }
-
-  static get DOG () {
-    return new Card(Color.DOG, 0)
-  }
-
-  static get MAHJONG () {
-    return new Card(Color.MAHJONG, 1)
-  }
-
-  static get PHOENIX () {
-    return new Card(Color.PHOENIX, 0)
-  }
-
-  static get DRAGON () {
-    return new Card(Color.DRAGON, 15)
-  }
-
-  static fromObject (obj) {
-    return Object.assign(new Card(-1, -1), obj)
-  }
-
-  toObject () {
-    return Object.assign({}, this)
-  }
-}
-
-export class Trick {
-
-  /**
-   * Default constructor. Prefer using the other constructors to create Trick objects.
-   *
-   * It will throw an
-   *
-   * @param {number} kind
-   * @param {number} value
-   * @param {number} [length=0]  Should be an integer.
-   *
-   * @throws {Error} when length is not valid for the given `kind`.
-   */
-  constructor (kind, value, length) {
-    if (length == null) {
-      length = 0
-    }
-
-    /** @type {number} */
-    this.kind = kind
-
-    /** @type {number} */
-    this._value = value
-
-    /** @type {number} */
-    this.length = length
-
-    switch (kind) {
-      case TrickKind.INVALID:
-        break
-      case TrickKind.SIMPLE:
-        this.length = 1
-        break
-      case TrickKind.DOUBLE:
-        this.length = 2
-        break
-      case TrickKind.TRIPLE:
-        this.length = 3
-        break
-      case TrickKind.FULL_HOUSE:
-        this.length = 5
-        break
-      default:
-        if (length < 4 || 14 < length) {
-          throw new Error(`Trick constructor invalid arguments, kind: ${kind} length: ${length}`)
-        }
-        this.length = length
-    }
-  }
-
-  /**
-   * @returns {boolean}
-   */
-  isValid () {
-    return this.kind !== TrickKind.INVALID
-  }
-
-  /**
-   * @returns {boolean}
-   */
-  isBomb () {
-    return this.kind === TrickKind.BOMB
-  }
-
-  /**
-   * @returns {number}
-   */
-  get value () {
-    if (this.kind === TrickKind.BOMB) {
-      return this._value + 100 * this.length
-    } else {
-      return this._value
-    }
-  }
-
-  /**
-   * @returns {string}
-   */
-  name () {
-    let kindName
-    switch (this.kind) {
-      case TrickKind.INVALID:
-        kindName = 'Invalid trick'
-        break
-      case TrickKind.SIMPLE:
-        kindName = 'High card'
-        break
-      case TrickKind.DOUBLE:
-        kindName = 'Pair'
-        break
-      case TrickKind.TRIPLE:
-        kindName = 'Triple'
-        break
-      case TrickKind.FULL_HOUSE:
-        kindName = 'Full'
-        break
-      case TrickKind.SUIT:
-        kindName = 'Suit'
-        break
-      case TrickKind.PAIR_SUIT:
-        kindName = 'Suit of pairs'
-        break
-      case TrickKind.BOMB:
-        kindName = 'Bomb'
-        break
-    }
-    return `${kindName} (${this._value}, ${this.length})`
-  }
-
-  // Constructors
-
-  /**
-   * @return {Trick}
-   * @constructor
-   */
-  static get INVALID () {
-    return new Trick(TrickKind.INVALID, -1)
-  }
-
-  /**
-   * @return {Trick}
-   * @constructor
-   */
-  static SIMPLE (value) {
-    return new Trick(TrickKind.SIMPLE, value)
-  }
-
-  /**
-   * @return {Trick}
-   * @constructor
-   */
-  static DOUBLE (value) {
-    return new Trick(TrickKind.DOUBLE, value)
-  }
-
-  /**
-   * @return {Trick}
-   * @constructor
-   */
-  static TRIPLE (value) {
-    return new Trick(TrickKind.TRIPLE, value)
-  }
-
-  /**
-   * @return {Trick}
-   * @constructor
-   */
-  static FULL_HOUSE (value) {
-    return new Trick(TrickKind.FULL_HOUSE, value)
-  }
-
-  /**
-   * @return {Trick}
-   * @constructor
-   */
-  static SUIT (ncards, value) {
-    return new Trick(TrickKind.SUIT, value, ncards)
-  }
-
-  /**
-   * @return {Trick}
-   * @constructor
-   */
-  static PAIR_SUIT (ncards, value) {
-    return new Trick(TrickKind.PAIR_SUIT, value, ncards)
-  }
-
-  /**
-   * @return {Trick}
-   * @constructor
-   */
-  static BOMB (ncards, value) {
-    return new Trick(TrickKind.BOMB, value, ncards)
-  }
-
-  /**
-   * Find out the kind of trick from a sorted array of cards.
-   * The cards must be sorted from the higgest value to the lowest.
-   *
-   * @param {Card[]} cards
-   * @returns {Trick}
-   */
-  static fromSortedArray (cards) {
-    if (cards.length === 0) return Trick.INVALID
-    switch (cards.length) {
-      case 1:
-        let card = cards[0]
-        return Trick.SIMPLE(card.isPhoenix() ? -1 : card.value)
-      case 2:
-        return (cards[0].value === cards[1].value) ? Trick.DOUBLE(cards[0].value) : Trick.INVALID
-      case 3:
-        return (cards[0].value === cards[1].value && cards[0].value === cards[2].value)
-          ? Trick.TRIPLE(cards[0].value)
-          : Trick.INVALID
-      case 4:
-        if (cards[0].value === cards[1].value && cards[2].value === cards[3].value) {
-          if (cards[1].value === cards[2].value && cards.every(c => !c.isPhoenix())) {
-            return Trick.BOMB(4, cards[1].value)
-          } else if (cards[1].value - 1 === cards[2].value) {
-            return Trick.PAIR_SUIT(cards[0].value)
-          }
-        }
-        return Trick.INVALID
-      case 5:
-        if (cards[0].value === cards[1].value && cards[0].value === cards[2].value && cards[3].value === cards[4].value) {
-          return Trick.FULL_HOUSE(cards[0].value)
-        } else if (cards[0].value === cards[1].value && cards[2].value === cards[3].value && cards[2].value === cards[4].value) {
-          return Trick.FULL_HOUSE(cards[2].value)
-        }
-      /* FALLTHROUGH */
-      default:
-        if (cards.length % 2 === 0 && cards.every((c, i, cards) => cards[0].value - c.value === Math.floor(i / 2))) {
-          return Trick.PAIR_SUIT(cards[0].value)
-        } else if (cards.every((c, i, cards) => cards[0].value - c.value === i)) {
-          return cards.every(c => c.color === cards[0].color && !c.isPhoenix())
-            ? Trick.BOMB(cards.length, cards[0].value)
-            : Trick.SUIT(cards.length, cards[0].value)
-        } else {
-          return Trick.INVALID
-        }
-    }
-  }
-}
-
-export class CardSet {
-
-  /**
-   * Default constructor.
-   *
-   * @param {Card[]} [cards=[]]  Initial set of cards.
-   */
-  constructor (cards) {
-    if (cards == null) {
-      cards = []
-    }
-
-    /**
-     * The underlying card array. It is sorted.
-     *
-     * @type {Card[]}
-     * @private
-     */
-    this._data = new SortedArraySet(cards, cardsEqual, compareCards, () => Card.DOG)
-
-    /**
-     * The trick corresponding to this CardSet, or null if it has not be computed yet.
-     * It is used to not compute the trick every time `CardSet.trick` is called.
-     *
-     * @type {?Trick}
-     * @private
-     */
-    this._cached_trick = null
-  }
-
-  // SET METHODS
-
-  clear () {
-    this._data.clear()
-    this._cached_trick = null
-  }
-
-  /**
-   * Adds the given cards to the set.
-   *
-   * @param {Card|Card[]|CardSet} cards
-   */
-  add (cards) {
-    if (cards.constructor === Card) {
-      cards = [cards]
-    } else if (cards.constructor === CardSet) {
-      cards = cards._data.toArray()
-    }
-
-    this._data.push(...cards)
-    this._cached_trick = null
-  }
-
-  /**
-   * Whether this CardSet has the given card.
-   *
-   * @param {Card} card
-   * @return {Boolean}
-   */
-  has (card) {
-    return this._data.has(card)
-  }
-
-  get length () {
-    return this._data.length
-  }
-
-  /**
-   * Remove a card from this CardSet.
-   *
-   * @param {Card} card
-   * @return {boolean}  Whether the given card has been removed.
-   */
-  remove (card) {
-    this._cached_trick = null
-    return this._data.remove(card)
-  }
-
-  /**
-   * This CardSet as an Array.
-   *
-   * @return {Card[]}
-   */
-  toArray () {
-    return this._data.toArray()
-  }
-
-  // CARDS METHODS
-
-  /**
-   * Whether this CardSet can be played over the `other` CardSet.
-   *
-   * @param {CardSet} other
-   * @returns {boolean}
-   */
-  canBePlayedOver (other) {
-    if (other._data.length === 0 || (this.trick().isBomb() && !other.trick().isBomb())) {
-      // Rien n'a été joué.
-      // ou
-      // On a joué une bombe sur une combinaison normale.
-      return true
-    } else if ((!other.trick().isBomb() && this._data.length !== other._data.length) || (!this.trick().isBomb() && other.trick().isBomb())) {
-      // On joue sur une combinaison normale, et notre combinaison n'a pas le même nombre de cartes que l'autre.
-      // ou
-      // On joue une combinaison normale sur une bombe.
-      return false
-    } else if (this.trick().length === 1 && this.has(Card.PHOENIX)) {
-      return !other.has(Card.DRAGON)
-    } else {
-      return this.trick().value - other.trick().value > 0
-    }
-  }
-
-  /**
-   * The number of points this CardSet is worth.
-   *
-   * @return {number}
-   */
-  points () {
-    return this._data.reduce((p, c) => p + c.points(), 0)
-  }
-
-  /**
-   * The trick corresponding to this CardSet.
-   *
-   * @returns {Trick}
-   */
-  trick () {
-    if (this._cached_trick == null) {
-      this._cached_trick = Trick.fromSortedArray(this._data.toArray())
-    }
-    return this._cached_trick
-  }
-
-  static fromObject (obj) {
-    return new CardSet(obj.map(Card.fromObject))
-  }
-
-  toObject () {
-    return this.toArray().map(c => c.toObject())
-  }
-}
diff --git a/api/games/games.ts b/api/games/games.ts
index 1b1c1a7db31adc90836f80efb2d809fc6cd78e5e..8cfe53c5150fadccb70b2c67e3c02e0187e7d019 100644
--- a/api/games/games.ts
+++ b/api/games/games.ts
@@ -3,8 +3,7 @@ import SimpleSchema from 'simpl-schema'
 import { ValidatedMethod } from 'meteor/mdg:validated-method'
 import { loggedInMixin, schemaMixin } from '../mixins'
 import { Meteor } from 'meteor/meteor'
-import { Card } from './cards'
-import { Bet, GameSystem } from './logic'
+import { Bet, Card, GameSystem, GameSystemData, Place } from './logic'
 
 export const Games = new Mongo.Collection('games')
 
@@ -19,7 +18,7 @@ export function notInGameMixin<A, R> (method: ValidatedMethod<A, R>) {
 
 export function inGameMixin<A, R> (method: ValidatedMethod<A, R>) {
   let run = method.run
-  method.run = function (...params: any[]) {
+  method.run = function (this: { game: any, userId: string }, ...params: any[]) {
     this.game = Games.findOne({'players.id': this.userId})
     if (this.game == null) throw new Meteor.Error('not_in_game')
     this.game.sys = GameSystem.fromObject(this.game.sys)
@@ -28,41 +27,38 @@ export function inGameMixin<A, R> (method: ValidatedMethod<A, R>) {
   return method
 }
 
-const PlayerSchema = new SimpleSchema({
-  id: String,
-  position: {type: Number, allowedValues: [0, 1, 2, 3]}
-})
-
-const GameSchema = new SimpleSchema({
-  name: String,
-  players: {type: Array, defaultValue: [], optional: true},
-  'players.$': PlayerSchema,
-  sys: {type: GameSystem, defaultValue: new GameSystem(), optional: true}
-})
-
-interface Player {
+export class PlayerPosition {
   id: string
-  position: number
+  position: Place
 }
 
-interface Game {
+export class Game {
   _id: string
   name: string
-  players: Player[]
+  players: PlayerPosition[]
   sys: GameSystem
 }
 
+class GameData {
+  _id: string
+  name: string
+  players: PlayerPosition[]
+  sys: GameSystemData
+}
+
 export const newGame = new ValidatedMethod<{ name: string }, string>({
   name: 'Game.new',
   mixins: [schemaMixin, loggedInMixin, notInGameMixin],
   validate: new SimpleSchema({
     name: String
   }),
-  run (game) {
-    if (Games.find(game).count() === 0) {
-      let gameClean: Game = GameSchema.clean(game) as Game
-      gameClean.sys = gameClean.sys.toObject()
-      return Games.insert(gameClean)
+  run ({name}) {
+    if (Games.find({name}).count() === 0) {
+      return Games.insert({
+        name,
+        players: [],
+        sys: new GameSystem().toObject()
+      })
     } else {
       throw new Meteor.Error('name_taken')
     }
@@ -76,14 +72,14 @@ export const joinGame = new ValidatedMethod<{ _id: string }, void>({
     _id: String
   }),
   run ({_id}) {
-    let game = Games.findOne({_id}) as Game
+    let game: GameData | null = Games.findOne({_id}) as GameData | null
     if (game == null) throw new Meteor.Error('game_not_found')
     if (game.players.length === 4) throw new Meteor.Error('game_full')
-    game.players.push({id: this.userId, position: game.players.length})
+    game.players.push({id: this.userId, position: <Place>(game.players.length)})
     if (game.players.length === 4) {
-      game.sys = GameSystem.fromObject(game.sys)
-      game.sys.newRound()
-      game.sys = game.sys.toObject()
+      let sys = GameSystem.fromObject(game.sys)
+      sys.newRound()
+      game.sys = sys.toObject()
     }
     Games.update({_id}, {$set: game})
   }
@@ -96,14 +92,14 @@ export const giveCards = newGameMethod('giveCards', new SimpleSchema({
   'cards.$': Card
 }), 'cards')
 
-export const playCards = new ValidatedMethod({
+export const playCards = new ValidatedMethod<{ cards: Card[] }, void>({
   name: 'Game.playCards',
   mixins: [schemaMixin, loggedInMixin, inGameMixin],
   validate: new SimpleSchema({
     cards: [Card]
   }),
   run ({cards}) {
-    let player = this.game.players.findIndex(p => p.id === this.userId)
+    let player = this.game.players.findIndex((p: PlayerPosition) => p.id === this.userId)
 
     if (!this.game.sys.playCards(player, cards)) {
       throw new Meteor.Error('playCards')
diff --git a/api/games/logic.js b/api/games/logic.js
deleted file mode 100644
index 643fe21adb18dd8e73b1435d0b7a15aedf82ac8f..0000000000000000000000000000000000000000
--- a/api/games/logic.js
+++ /dev/null
@@ -1,646 +0,0 @@
-import { Card, CardSet, Color } from './cards'
-
-export const NUM_PLAYERS = 4
-//export const NUM_CARDS = 56
-
-export const Bet = {
-  NONE: 0,
-  TICHU: 100,
-  GRAND: 200
-}
-
-export const GameState = {
-  GIVING_CARDS: 1,
-  PLAYING: 2,
-  GIVING_DRAGON: 3,
-
-  initial () {
-    return 1
-  }
-}
-
-/**
- * All the not-special cards.
- * @returns {Card[]}
- */
-export function partialDeck () {
-  let deck = []
-  for (let color of [Color.BLUE, Color.GREEN, Color.RED, Color.BLACK]) {
-    for (let value = 2; value < 15; value++) {
-      deck.push(new Card(color, value))
-    }
-  }
-  return deck
-}
-
-/**
- * The deck of all cards.
- *
- * @return {Card[]}
- */
-export function fullDeck () {
-  return [Card.DOG, Card.MAHJONG, Card.PHOENIX, Card.DRAGON].concat(partialDeck())
-}
-
-/**
- * Shuffles an array.
- *
- * @param {Array} arr
- * @return {Array}
- */
-export function shuffle (arr) {
-  for (let i = arr.length - 1; i > 0; i--) {
-    const j = Math.floor(Math.random() * (i + 1));
-    [arr[i], arr[j]] = [arr[j], arr[i]]
-  }
-  return arr
-}
-
-/**
- * Split an array into n chunks of equal length
- *
- * @param {*[]} arr
- * @param {number} n
- */
-export function splitChunk (arr, n) {
-  let chunkLength = arr.length / n
-  let res = []
-  for (let i = 0; i < n; i++) {
-    res.push(arr.slice(i * chunkLength, (i + 1) * chunkLength))
-  }
-  return res
-}
-
-export class Player {
-  constructor () {
-    /**
-     * @type {CardSet}
-     */
-    this.hand = new CardSet()
-
-    /**
-     * @type {number}
-     */
-    this.bet = Bet.NONE
-
-    /**
-     * The cards that the player has earned.
-     *
-     * @type {CardSet}
-     */
-    this.tricks = new CardSet()
-
-    /**
-     * @type {Card[]}
-     */
-    this.cardsGiven = []
-
-    /**
-     * True if the player has seen all its cards.
-     *
-     * @type {boolean}
-     */
-    this.hasNextCards = false
-  }
-
-  newRound (cards) {
-    this.hand.clear()
-    this.hand.add(cards)
-    this.bet = Bet.NONE
-    this.tricks.clear()
-    this.cardsGiven = []
-    this.hasNextCards = false
-  }
-
-  static fromObject (obj) {
-    let player = Object.assign(new Player(), obj)
-
-    player.hand = CardSet.fromObject(player.hand)
-    player.tricks = CardSet.fromObject(player.tricks)
-
-    for (let i = 0; i < player.cardsGiven.length; i++) {
-      player.cardsGiven[i] = Card.fromObject(player.cardsGiven[i])
-    }
-
-    return player
-  }
-
-  toObject () {
-    let obj = Object.assign({}, this)
-
-    obj.hand = obj.hand.toObject()
-    obj.tricks = obj.tricks.toObject()
-
-    for (let i = 0; i < obj.cardsGiven.length; i++) {
-      obj.cardsGiven[i] = obj.cardsGiven[i].toObject()
-    }
-
-    return obj
-  }
-}
-
-export class GameSystem {
-  constructor () {
-    /**
-     * The array of players.
-     * The (n+1)th player is at the right of the nth player, modulo the number of players.
-     *
-     * @type {Player[]}
-     * @protected
-     */
-    this._players = []
-
-    /**
-     * The number of points the team formed by the 0th and the 2th player has earned during each round.
-     *
-     * @type {number[]}
-     * @private
-     */
-    this._points02 = []
-
-    /**
-     * The number of points the team formed by the 1th and the 3th player has earned during each round.
-     *
-     * @type {number[]}
-     * @private
-     */
-    this._points13 = []
-
-    /**
-     * The discard, as a stack of the played tricks.
-     *
-     * @type {CardSet[]}
-     * @protected
-     */
-    this._discard = []
-
-    /**
-     * The state of the game.
-     *
-     * @type {number}
-     * @protected
-     */
-    this._state = GameState.initial()
-
-    /**
-     * The first player that has finished the round, or -1 if there is none.
-     *
-     * @type {number}
-     * @private
-     */
-    this._first = -1
-
-    /**
-     * The player that has to play, or -1 if there is none (GameState != PLAYING)
-     *
-     * @type {number}
-     * @protected
-     */
-    this._playing = -1
-
-    /**
-     * The player that will get the discard if the others skip turns, or -1 if there is none.
-     *
-     * @type {number}
-     * @protected
-     */
-    this._lead = -1
-
-    /**
-     * The point cap to reach for the game to end.
-     *
-     * @type {number}
-     * @private
-     */
-    this._maxPoints = 100
-
-    for (let i = 0; i < NUM_PLAYERS; i++) {
-      this._players.push(new Player())
-    }
-  }
-
-  ///////////
-  // UTILS //
-  ///////////
-
-  /**
-   * Whether the game is over.
-   *
-   * @return {number}  -1 if the game is not over
-   *                    0 if the team 02 wins
-   *                    1 if the team 13 wins
-   */
-  winner () {
-    let points02 = this._points02.reduce((s, p) => s + p)
-    let points13 = this._points13.reduce((s, p) => s + p)
-
-    if ((points02 >= this._maxPoints || points13 >= this._maxPoints) && points02 !== points13) {
-      return points02 > points13 ? 0 : 1
-    } else {
-      return -1
-    }
-  }
-
-  /**
-   * Counts the points of each player and add them to the points of the round.
-   * A new round must start after a call to this method.
-   *
-   * @private
-   */
-  _countPoints () {
-    let unfinished = this._players.filter(p => p.hand.length > 0)
-    let points02 = 0
-    let points13 = 0
-
-    if (unfinished.length === 1) {
-      let last = unfinished[0]
-
-      if (last % 2 === 0) {
-        // Last player is in team 02
-        points13 += this._players[last].hand.points()
-      } else {
-        points02 += this._players[last].hand.points()
-      }
-
-      if (this._first % 2 === 0) {
-        // First player is in team 02
-        points02 += this._players[last].tricks.points()
-      } else {
-        points13 += this._players[last].tricks.points()
-      }
-
-      this._players[last].tricks.clear()
-      points02 += this._players[0].tricks.points() + this._players[2].tricks.points()
-      points13 += this._players[1].tricks.points() + this._players[3].tricks.points()
-    } else if (this._players[0].hand.length > 0) {
-      // One-Two. Team 13 wins
-      points02 = 0
-      points13 = 200
-    } else {
-      // One-Two. Team 02 wins
-      points02 = 200
-      points13 = 0
-    }
-
-    for (let player = 0; player < this._players.length; player++) {
-      let betPoints = (player === this._first ? 1 : -1) * this._players[player].bet
-      if (player % 2 === 0) {
-        points02 += betPoints
-      } else {
-        points13 += betPoints
-      }
-    }
-
-    this._points02.push(points02)
-    this._points13.push(points13)
-  }
-
-  _hasRoundFinished () {
-    return this._players.filter(p => p.hand.length > 0).length === 1 ||
-      (this._players[0].hand.length === 0 && this._players[2].hand.length === 0) ||
-      (this._players[1].hand.length === 0 && this._players[3].hand.length === 0)
-  }
-
-  /**
-   * Whether the given player has every given card in hand.
-   *
-   * @param {number} player  either 0, 1, 2 or 3
-   * @param {Card[]} cards
-   * @return {boolean}
-   * @private
-   */
-  _playerHasCards (player, cards) {
-    return cards.every(c => this._players[player].hand.has(c))
-  }
-
-  newRound () {
-    this._discard = []
-    this._state = GameState.initial()
-    this._first = -1
-    this._playing = -1
-    this._lead = -1
-
-    let deck = fullDeck()
-    shuffle(deck)
-    let hands = splitChunk(deck, NUM_PLAYERS)
-    for (let i = 0; i < this._players.length; i++) {
-      this._players[i].newRound(hands[i])
-    }
-  }
-
-  _startPlaying () {
-    this._state = GameState.PLAYING
-    this._playing = this._players.findIndex(p => p.hand.has(Card.MAHJONG))
-    this._lead = this._playing
-
-    for (let player = 0; player < NUM_PLAYERS; player++) {
-      for (let i = 0; i < 3; i++) {
-        let givenCard = this._players[player].cardsGiven[i]
-        this._players[(player - i + 3) % 4].hand.add(givenCard)
-      }
-    }
-  }
-
-  get points02 () {
-    return this._points02
-  }
-
-  get points13 () {
-    return this._points13
-  }
-
-  /////////////
-  // ACTIONS //
-  /////////////
-
-  /**
-   * A player give three cards.
-   *
-   * @param {number} player  either 0, 1, 2 or 3
-   * @param {Card[]} cards  must have 3 elements
-   * @return {boolean}  True if the cards can be given
-   */
-  giveCards (player, cards) {
-    if (this._state !== GameState.GIVING_CARDS || !this._playerHasCards(player, cards) || !this._players[player].hasNextCards) {
-      return false
-    }
-
-    cards.forEach(c => this._players[player].hand.remove(c))
-    this._players[player].cardsGiven = cards
-
-    if (this._players.every(p => p.cardsGiven.length > 0)) {
-      this._startPlaying()
-    }
-
-    return true
-  }
-
-  /**
-   * A player calls Tichu or Grand Tichu.
-   *
-   * @param {number} player  either 0, 1, 2 or 3
-   * @param {number} bet  either 100 or 200
-   * @return {boolean} True if the call can be made
-   */
-  makeCall (player, bet) {
-    if (this._players[player].bet !== Bet.NONE) {
-      return false
-    }
-
-    let res = true
-    if (bet === Bet.TICHU && (this._players[player].hand.length === 14 || (this._players[player].hand.length === 11 && this._state === GameState.GIVING_CARDS))) {
-      this._players[player].bet = Bet.TICHU
-    } else if (bet === Bet.GRAND && this._state === GameState.GIVING_CARDS && !this._players[player].hasNextCards) {
-      this._players[player].bet = Bet.GRAND
-      this._players[player].hasNextCards = true
-    } else {
-      res = false
-    }
-
-    return res
-  }
-
-  /**
-   * A player asks for the next cards (hasn't called grand tichu).
-   *
-   * @param {number} player  either 0, 1, 2 or 3
-   * @return {boolean}  Whether the player can have the next cards.
-   */
-  nextCards (player) {
-    if (this._state !== GameState.GIVING_CARDS || this._players[player].hasNextCards) {
-      return false
-    }
-
-    this._players[player].hasNextCards = true
-
-    return true
-  }
-
-  /**
-   * A player plays cards.
-   *
-   * @param {number} player  either 0, 1, 2 or 3
-   * @param {Card[]} cards
-   * @return {boolean} Whether the cards can be played.
-   */
-  playCards (player, cards) {
-    if (!this._playerHasCards(player, cards) || this._state !== GameState.PLAYING) {
-      return false
-    }
-
-    let played = new CardSet(cards)
-
-    if (this._playing !== player && !(played.trick().isBomb() && this._discard.length > 0)) {
-      // It's not the player's turn, and the player is not playing a valid bomb (a bomb over a non-empty discard).
-      return false
-    }
-
-    if (!played.canBePlayedOver(this._discard[this._discard.length - 1])) {
-      return false
-    }
-
-    if (played.length === 1 && played.has(Card.PHOENIX)) {
-      let card = Card.PHOENIX
-      card.value = this._discard[this._discard.length - 1].value + 0.5
-      played = new CardSet([card])
-    }
-
-    // Play the card and update this._playing according to the card played.
-    if (played.length === 1 && played.has(Card.DOG)) {
-      this._players[player].tricks.add(Card.DOG)
-      this._playing = (player + 2) % 4
-    } else {
-      this._discard.push(played)
-      this._playing = (player + 1) % 4
-    }
-
-    // Update this._lead and remove the cards from the players' hand.
-    this._lead = player
-    cards.forEach(c => this._players[player].hand.remove(c))
-
-    // Check if the player has finished, then if the game has finished.
-    if (this._players[player].hand.length === 0) {
-      if (this._first < 0) {
-        this._first = player
-      } else if (this._hasRoundFinished()) {
-        this._countPoints()
-        if (this.winner() < 0) {
-          this.newRound()
-        }
-        return true
-      }
-    }
-
-    // Update this._playing according to players that has already finished.
-    while (this._players[this._playing].hand.length === 0) {
-      this._playing = (this._playing + 1) % 4
-    }
-
-    return true
-  }
-
-  /**
-   * A player skips a turn.
-   *
-   * @param {number} player  either 0, 1, 2 or 3
-   * @return {boolean}  Whether the player can skip a turn.
-   */
-  skipTurn (player) {
-    if (this._state !== GameState.PLAYING || this._playing !== player || this._discard.length === 0) {
-      return false
-    }
-
-    // Update this._playing until it finds a player that has cards or the leader.
-    do {
-      this._playing = (this._playing + 1) % 4
-    } while (this._players[this._playing].hand.length === 0 && this._playing !== this._lead)
-
-    if (this._playing === this._lead) {
-      // The leader is found and can take the cards in the discard, or give the dragon.
-      if (this._discard[this._discard.length - 1].has(Card.DRAGON)) {
-        this._state = GameState.GIVING_DRAGON
-      } else {
-        this._discard.forEach(d => this._players[this._lead].tricks.add(d))
-        this._discard = []
-        // The leader might have no card left.
-        while (this._players[this._playing].hand.length === 0) {
-          this._playing = (this._playing + 1) % 4
-        }
-      }
-    }
-
-    return true
-  }
-
-  /**
-   * A player gives the dragon.
-   *
-   * @param {number} player  either 0, 1, 2 or 3
-   * @param {number} to  which player to give the dragon. Either 0 (player to the right) or 1 (to the left).
-   * @return {boolean}  Whether the dragon can be given.
-   */
-  giveDragon (player, to) {
-    if (this._state !== GameState.GIVING_DRAGON || this._lead !== player) {
-      return false
-    }
-
-    this._state = GameState.PLAYING
-    to = (player + (to === 0 ? 1 : 3)) % 4
-    this._discard.forEach(d => this._players[to].tricks.add(d))
-    this._discard = []
-    while (this._players[this._playing].hand.length === 0) {
-      this._playing = (this._playing + 1) % 4
-    }
-
-    return true
-  }
-
-  static fromObject (obj) {
-    let sys = Object.assign(new GameSystem(), obj)
-
-    for (let i = 0; i < sys._players.length; i++) {
-      sys._players[i] = Player.fromObject(sys._players[i])
-    }
-
-    for (let i = 0; i < sys._discard.length; i++) {
-      sys._discard[i] = CardSet.fromObject(sys._discard[i])
-    }
-
-    return sys
-  }
-
-  toObject () {
-    let obj = Object.assign({}, this)
-
-    for (let i = 0; i < obj._players.length; i++) {
-      obj._players[i] = obj._players[i].toObject()
-    }
-
-    for (let i = 0; i < obj._discard.length; i++) {
-      obj._discard[i] = obj._discard[i].toObject()
-    }
-
-    return obj
-  }
-}
-
-export class GamePov {
-  /**
-   * A game from the point of view of a player placed at a given place.
-   *
-   * @param {GameSystem} game
-   * @param {number} place  either 0, 1, 2 or 3
-   */
-  constructor (game, place) {
-    /**
-     * Underlying game.
-     *
-     * @type {GameSystem}
-     * @private
-     */
-    this._game = game
-
-    /**
-     * The place of the player.
-     *
-     * @type {number}
-     * @private
-     */
-    this._place = place
-  }
-
-  get hand () {
-    return this._game._players[this._place].hand.toArray()
-  }
-
-  get givingCards () {
-    return this._game._state === GameState.GIVING_CARDS
-  }
-
-  get givingDragon () {
-    return this._game._state === GameState.GIVING_DRAGON && this._game._playing === this._place
-  }
-
-  isPlaying (place) {
-    place = this._absolutePlace(place)
-    return this._game._playing === ((this._place + place) % 4)
-  }
-
-  hasLead (place) {
-    place = this._absolutePlace(place)
-    return this._game._lead === ((this._place + place) % 4)
-  }
-
-  get discard () {
-    if (this._game._discard.length > 0) {
-      return this._game._discard[this._game._discard.length - 1].toArray()
-    } else {
-      return null
-    }
-  }
-
-  bet (place) {
-    place = this._absolutePlace(place)
-    return this._game._players[place].bet
-  }
-
-  numCards (place) {
-    place = this._absolutePlace(place)
-    return this._game._players[place].hand.length
-  }
-
-  /**
-   * From a place relative to this._place returns the absolute place in the game.
-   *
-   * @param {number} [rel]  either 0 (west), 1 (north), 2 (east) or null (south)
-   * @return {number}
-   * @private
-   */
-  _absolutePlace (rel) {
-    if (rel == null) {
-      return this._place
-    } else {
-      return (this._place - rel + 3) % 4
-    }
-  }
-}
diff --git a/api/games/logic.ts b/api/games/logic.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c3f10e91f2e27e8af60f853e53fb2eb7acfdcd83
--- /dev/null
+++ b/api/games/logic.ts
@@ -0,0 +1,8 @@
+export * from './_logic/Bet'
+export * from './_logic/Card'
+export * from './_logic/CardSet'
+export * from './_logic/deck'
+export * from './_logic/GamePov'
+export * from './_logic/GameSystem'
+export * from './_logic/Player'
+export * from './_logic/Trick'
diff --git a/api/mixins.js b/api/mixins.ts
similarity index 59%
rename from api/mixins.js
rename to api/mixins.ts
index 8b0dd4e3f96cc9f95a422c35bb46ac99f20c7a45..ae2e7ae7f5e0f5e5a41f5966306f7605b3bc1107 100644
--- a/api/mixins.js
+++ b/api/mixins.ts
@@ -1,11 +1,12 @@
 import { Meteor } from 'meteor/meteor'
+import { ValidatedMethod } from 'meteor/mdg:validated-method'
 
-export const schemaMixin = method => {
+export function schemaMixin<A, R> (method: ValidatedMethod<A, R>) {
   method.validate = method.validate.validator({ clean: true })
   return method
 }
 
-export const loggedInMixin = method => {
+export function loggedInMixin<A, R> (method: ValidatedMethod<A, R>) {
   let run = method.run
   method.run = function () {
     if (!this.userId) throw new Meteor.Error('not_logged_in')
diff --git a/client/main.ts b/client/main.ts
index 5a64fe36d39111184160e0f265342c63c9412bf7..909a0b715928c3ae25076fe11d132222cff35e41 100644
--- a/client/main.ts
+++ b/client/main.ts
@@ -1,8 +1,8 @@
 import { Meteor } from 'meteor/meteor'
 import { render } from 'react-dom'
 
-import '../startup/accounts'
-import routes from '../startup/routes'
+import '../api/accounts'
+import routes from '../ui/routes'
 
 Meteor.startup(() => {
   let $app = document.createElement('div')
diff --git a/package-lock.json b/package-lock.json
index a4841c2748a3c78fcccac0fd883cd1994adabba6..b0bad44b4f4b7bb81348665f5f12363490d39313 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -128,6 +128,11 @@
         "to-fast-properties": "^2.0.0"
       }
     },
+    "@types/collections": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/@types/collections/-/collections-5.0.0.tgz",
+      "integrity": "sha512-hP3yPM7ePW89pKcVCnCTnzSaKdr7QQaz/fGfj8YUSIbWAmvNM/QVRWXpmh7bh19u2x006FPWBHow6ZX3zOL5lA=="
+    },
     "@types/connect": {
       "version": "3.4.32",
       "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.32.tgz",
@@ -155,6 +160,11 @@
       "resolved": "https://registry.npmjs.org/@types/node/-/node-10.11.7.tgz",
       "integrity": "sha512-yOxFfkN9xUFLyvWaeYj90mlqTJ41CsQzWKS3gXdOMOyPVacUsymejKxJ4/pMW7exouubuEeZLJawGgcNGYlTeg=="
     },
+    "@types/pixi.js": {
+      "version": "4.8.1",
+      "resolved": "https://registry.npmjs.org/@types/pixi.js/-/pixi.js-4.8.1.tgz",
+      "integrity": "sha512-aKAnT85e8fGJvHjLAVJoJuYg3S1vZxenCEwkj/kvGCBpcxZtxHi/okC3u23rJAsnoo/keBbfWyUIdijSIhwjSg=="
+    },
     "@types/prop-types": {
       "version": "15.5.6",
       "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.5.6.tgz",
@@ -652,6 +662,11 @@
         }
       }
     },
+    "bit-twiddle": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/bit-twiddle/-/bit-twiddle-1.0.2.tgz",
+      "integrity": "sha1-DGwfq+KyPRcXPZpht7cJPrnhdp4="
+    },
     "brace-expansion": {
       "version": "1.1.11",
       "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -765,6 +780,11 @@
       "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==",
       "dev": true
     },
+    "earcut": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.1.3.tgz",
+      "integrity": "sha512-AxdCdWUk1zzK/NuZ7e1ljj6IGC+VAdC3Qb7QQDsXpfNrc5IM8tL9nNXUmEGE6jRHTfZ10zhzRhtDmWVsR5pd3A=="
+    },
     "escape-string-regexp": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
@@ -882,6 +902,11 @@
       "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
       "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
     },
+    "ismobilejs": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-0.4.1.tgz",
+      "integrity": "sha1-Gl8SbHD+05yT2jgPpiy65XI+fcI="
+    },
     "istanbul-lib-coverage": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz",
@@ -1669,6 +1694,11 @@
         }
       }
     },
+    "mini-signals": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/mini-signals/-/mini-signals-1.2.0.tgz",
+      "integrity": "sha1-RbCAE8X65RokqhqTXNMXye1yHXQ="
+    },
     "minimatch": {
       "version": "3.0.4",
       "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
@@ -2879,6 +2909,11 @@
         "wrappy": "1"
       }
     },
+    "parse-uri": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/parse-uri/-/parse-uri-1.0.0.tgz",
+      "integrity": "sha1-KHLcwi8aeXrN4Vg9igrClVLdrCA="
+    },
     "path-is-absolute": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -2906,6 +2941,33 @@
         "eventemitter3": "^3.1.0"
       }
     },
+    "pixi-gl-core": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/pixi-gl-core/-/pixi-gl-core-1.1.4.tgz",
+      "integrity": "sha1-i0tcQzsx5Bm8N53FZc4bg1qRs3I="
+    },
+    "pixi.js": {
+      "version": "4.8.2",
+      "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-4.8.2.tgz",
+      "integrity": "sha512-OHA3Q3wwxRJXkVWALVuiUcUqQZd5p0rQF9ikCvOmux3A6Lxb5S61v4PMEAVgR3+1auZekbv/GNHCxDGFCQSi8g==",
+      "requires": {
+        "bit-twiddle": "^1.0.2",
+        "earcut": "^2.1.3",
+        "eventemitter3": "^2.0.0",
+        "ismobilejs": "^0.4.0",
+        "object-assign": "^4.0.1",
+        "pixi-gl-core": "^1.1.4",
+        "remove-array-items": "^1.0.0",
+        "resource-loader": "^2.1.1"
+      },
+      "dependencies": {
+        "eventemitter3": {
+          "version": "2.0.3",
+          "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
+          "integrity": "sha1-teEHm1n7XhuidxwKmTvgYKWMmbo="
+        }
+      }
+    },
     "prop-types": {
       "version": "15.6.2",
       "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz",
@@ -2989,11 +3051,25 @@
       "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz",
       "integrity": "sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg=="
     },
+    "remove-array-items": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/remove-array-items/-/remove-array-items-1.0.0.tgz",
+      "integrity": "sha1-B79CyzMvTPboXq2DteToltIyayE="
+    },
     "resolve-pathname": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-2.2.0.tgz",
       "integrity": "sha512-bAFz9ld18RzJfddgrO2e/0S2O81710++chRMUxHjXOYKF6jTAMrUNZrEZ1PvV0zlhfjidm08iRPdTLPno1FuRg=="
     },
+    "resource-loader": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/resource-loader/-/resource-loader-2.1.1.tgz",
+      "integrity": "sha512-jRMGYUfa4AGk9ib45Wxc93lobhQVoiCUAUkWqsbb/fhGPge97YT1S8aC0xBEQpolMsrdmB3o7SH8VmIEvIDOLA==",
+      "requires": {
+        "mini-signals": "^1.1.1",
+        "parse-uri": "^1.0.0"
+      }
+    },
     "schedule": {
       "version": "0.5.0",
       "resolved": "https://registry.npmjs.org/schedule/-/schedule-0.5.0.tgz",
diff --git a/package.json b/package.json
index 37d7a18a863ba82c75de58d2eeb502fc948ede78..4175dd19606b216fe5d89736ad151f49fcb2e5a5 100644
--- a/package.json
+++ b/package.json
@@ -9,8 +9,10 @@
   },
   "dependencies": {
     "@babel/runtime": "^7.0.0",
+    "@types/collections": "^5.0.0",
     "@types/history": "^4.7.0",
     "@types/meteor": "^1.4.22",
+    "@types/pixi.js": "^4.8.1",
     "@types/react-dom": "^16.0.9",
     "@types/react-router": "^4.0.32",
     "@types/react-router-dom": "^4.3.1",
@@ -20,6 +22,7 @@
     "history": "^4.7.2",
     "meteor-node-stubs": "^0.4.1",
     "phaser": "^3.14.0",
+    "pixi.js": "^4.8.2",
     "react": "^16.5.2",
     "react-dom": "^16.5.2",
     "react-router": "^4.3.1",
@@ -29,7 +32,7 @@
   "meteor": {
     "mainModule": {
       "client": "client/main.ts",
-      "server": "server/main.js"
+      "server": "server/main.ts"
     }
   },
   "devDependencies": {
diff --git a/server/main.js b/server/main.ts
similarity index 100%
rename from server/main.js
rename to server/main.ts
diff --git a/tsconfig.json b/tsconfig.json
index 80d7d97d5404f03461a434225a2e02a60b7a96e6..e4ca855b1ca981fea01ef3efd3b811eefacdf4f9 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,10 +1,12 @@
 {
   "compilerOptions": {
-    "target": "es5",
+    "target": "es6",
+    "module": "commonjs",
     "removeComments": true,
     "sourceMap": true,
     "downlevelIteration": true,
     "jsx": "react",
+    "lib": ["dom", "es5", "es6"],
 
     "alwaysStrict": true,
     "noImplicitAny": true,
diff --git a/typings/collections.d.ts b/typings/collections.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d6c76b9a5eb715172457af81efa738d6c948c673
--- /dev/null
+++ b/typings/collections.d.ts
@@ -0,0 +1,16 @@
+declare module 'collections/sorted-array-set' {
+  import SortedSet = require('collections/sorted-set')
+
+  export class SortedArraySet<T> extends SortedSet<T> {
+    constructor (
+      values?: T[],
+      equals?: (a: T, b: T) => boolean,
+      compare?: (a: T, b: T) => number,
+      getDefault?: any
+    )
+
+    length: number
+
+    toArray (): T[]
+  }
+}
\ No newline at end of file
diff --git a/typings/globals.d.ts b/typings/globals.d.ts
index ed1c1bb3b0dfa48a566379ea9cb83c4dbe826b50..43a64f4923fdcbd3a7db0a0934987707cf76f696 100644
--- a/typings/globals.d.ts
+++ b/typings/globals.d.ts
@@ -1,7 +1,5 @@
-interface Function {
-  name: string
-}
-
+//*
 interface Array<T> {
   findIndex (predicate: (value: T, index: number, obj: Array<T>) => boolean, thisArg?: any): number
 }
+// */
diff --git a/typings/mdg_validated-method.d.ts b/typings/mdg_validated-method.d.ts
index c3cbe6d1506689f283acade3cb50688a3cab108a..f7d11b91d7080e0d64d75f09bd7fb8a422ad402b 100644
--- a/typings/mdg_validated-method.d.ts
+++ b/typings/mdg_validated-method.d.ts
@@ -3,20 +3,22 @@ declare module 'meteor/mdg:validated-method' {
 
   module MeteorValidatedMethod {
     export class ValidatedMethod<A, R> {
-      constructor (options: ValidatedMethodOptions<A, R>);
+      constructor (options: ValidatedMethodOptions<A, R>)
 
-      call (options?: A, cb?: (err: Error, res: R) => void): void;
+      validate: any
 
-      run (this: any, opts: A): R;
+      call (options?: A, cb?: (err: Error, res: R) => void): void
+
+      run (this: any, opts: A): R
     }
 
     interface ValidatedMethodOptions<A, R> {
-      name: string;
-      mixins?: ((method: ValidatedMethod<A, R>) => ValidatedMethod<A, R>)[];
-      validate: any;
-      applyOptions?: any;
+      name: string
+      mixins?: ((method: ValidatedMethod<A, R>) => ValidatedMethod<A, R>)[]
+      validate: any
+      applyOptions?: any
 
-      run (this: any, opts: A): R;
+      run (this: any, opts: A): R
     }
   }
 }
\ No newline at end of file
diff --git a/typings/react-meteor-data.d.ts b/typings/react-meteor-data.d.ts
index ef495556f398c215b519fdad87f1e23cd4aa3c28..d27c6e54106d3591d193ae4e6cfc08bebb16a1c6 100644
--- a/typings/react-meteor-data.d.ts
+++ b/typings/react-meteor-data.d.ts
@@ -1,5 +1,12 @@
 declare module 'meteor/react-meteor-data' {
+  class Match {
+    params: { [key: string]: string }
+    isExact: boolean
+    path: string
+    url: string
+  }
+
   function withTracker<T> (
-    getProps: () => T,
-  ): (component: React.ComponentClass<T> | React.StatelessComponent<T>) => React.ComponentClass<T>;
+    getProps: (query: { match: Match }) => T,
+  ): (component: React.ComponentClass<T> | React.StatelessComponent<T>) => React.ComponentClass<T>
 }
diff --git a/ui/_tichu_game/CardArray.ts b/ui/_tichu_game/CardArray.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/ui/_tichu_game/GameApplication.ts b/ui/_tichu_game/GameApplication.ts
new file mode 100644
index 0000000000000000000000000000000000000000..04975fdb77588e001b3a7d33334eeb583b11ae98
--- /dev/null
+++ b/ui/_tichu_game/GameApplication.ts
@@ -0,0 +1,34 @@
+import * as PIXI from 'pixi.js'
+import { ResponsiveApplication } from './ResponsiveApplication'
+import { partialDeck } from '../../api/games/_logic/deck'
+import { Game } from '../../api/games/games'
+import { Place } from '../../api/games/logic'
+
+export class GameApplication extends ResponsiveApplication {
+  constructor (el: HTMLDivElement) {
+    super()
+    el.appendChild(this.view)
+    this.loadTextures()
+  }
+
+  loadTextures (): void {
+    let normalCards = partialDeck().map(c => ({
+      name: `${c.color} ${c.value}`,
+      url: `/cards/${c.color}${c.value}.png`
+    }))
+    PIXI.loader
+      .add(normalCards)
+      .add('dragon', '/cards/dragon.png')
+      .add('phoenix', '/cards/phoenix.png')
+      .add('dog', '/cards/dog.png')
+      .add('mahjong', '/cards/mahjong.png')
+      .load(this.draw)
+  }
+
+  draw (): void {
+    let cat = new PIXI.Sprite(PIXI.loader.resources['dragon'].texture)
+    this.stage.addChild(cat)
+  }
+
+  update (game: Game, place: Place) {}
+}
diff --git a/ui/_tichu_game/Hand.ts b/ui/_tichu_game/Hand.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/ui/_tichu_game/ResponsiveApplication.ts b/ui/_tichu_game/ResponsiveApplication.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d3e1e3a064807e833e66cdbe851721f8f394347b
--- /dev/null
+++ b/ui/_tichu_game/ResponsiveApplication.ts
@@ -0,0 +1,13 @@
+import * as PIXI from 'pixi.js'
+
+export class ResponsiveApplication extends PIXI.Application {
+  constructor (options?: PIXI.ApplicationOptions) {
+    super(options)
+    this.renderer.view.style.position = "absolute"
+    this.renderer.view.style.display = "block"
+    this.renderer.view.style.padding = "0"
+    this.renderer.view.style.margin = "0"
+    this.renderer.autoResize = true
+    this.renderer.resize(window.innerWidth, window.innerHeight)
+  }
+}
\ No newline at end of file
diff --git a/ui/components/accounts_ui_wrapper.tsx b/ui/components/accounts_ui_wrapper.tsx
index 19394c2dea0950ab4fed81157d196ef0c5656a25..47c28522cda8c58c6c56734c43aa4735b0e14f7e 100644
--- a/ui/components/accounts_ui_wrapper.tsx
+++ b/ui/components/accounts_ui_wrapper.tsx
@@ -4,7 +4,7 @@ import { Blaze } from 'meteor/blaze'
 
 export default class AccountsUIWrapper extends React.Component<{}> {
   private view: Blaze.View
-  private readonly spanRef: React.RefObject<any>
+  private readonly spanRef: React.RefObject<HTMLSpanElement>
 
   constructor () {
     super({})
diff --git a/ui/components/game_entry.tsx b/ui/components/game_entry.tsx
index 6311e9f7dfec03e7da03c64e1a38976c4ba78d42..e7a8f8151e11a494e6af68c457b5c3e7bb62ef5c 100644
--- a/ui/components/game_entry.tsx
+++ b/ui/components/game_entry.tsx
@@ -1,10 +1,19 @@
 import * as React from 'react'
 import { Link } from 'react-router-dom'
+import { Game, joinGame } from '../../api/games/games'
+
+export default class GameEntry extends React.Component<{ game: Game }> {
+  onClick (event) {
+    joinGame.call({_id: this.props.game._id}, (err) => {
+      if (err) console.error(err)
+    })
+  }
 
-export default class GameEntry extends React.Component<{ game: {} }> {
   render () {
     return (
-      <li><Link to={'/games/' + this.props.game._id}>{this.props.game.name}</Link></li>
+      <li>
+        <Link onClick={this.onClick.bind(this)} to={'/games/' + this.props.game._id}>{this.props.game.name}</Link>
+      </li>
     )
   }
 }
diff --git a/ui/components/player_entry.jsx b/ui/components/player_entry.jsx
deleted file mode 100644
index 15e2f0b8714df57dbb1e2a1f1b20a158496a9999..0000000000000000000000000000000000000000
--- a/ui/components/player_entry.jsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import React from 'react'
-
-export default class GameEntry extends React.Component {
-  render () {
-    return (
-      <li>{this.props.player.id}</li>
-    )
-  }
-}
diff --git a/ui/components/player_entry.tsx b/ui/components/player_entry.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8147c49fbf6fd294ef5565a4e4ac35cdded53fc7
--- /dev/null
+++ b/ui/components/player_entry.tsx
@@ -0,0 +1,10 @@
+import * as React from 'react'
+import { PlayerPosition } from '../../api/games/games'
+
+export default class GameEntry extends React.Component<{ player: PlayerPosition }> {
+  render () {
+    return (
+      <li>{this.props.player.id}</li>
+    )
+  }
+}
diff --git a/ui/pages/game.jsx b/ui/pages/game.tsx
similarity index 57%
rename from ui/pages/game.jsx
rename to ui/pages/game.tsx
index 409233a5fe196a7c7909d04155d44b017e5b01e3..b12c0a9824a3a9b19b233eeccfa8003c97ecefaf 100644
--- a/ui/pages/game.jsx
+++ b/ui/pages/game.tsx
@@ -1,23 +1,40 @@
 import { Meteor } from 'meteor/meteor'
-import React from 'react'
+import * as React from 'react'
 import { withTracker } from 'meteor/react-meteor-data'
-import { Games, joinGame } from '../../api/games/games.ts'
-import PlayerEntry from '../components/player_entry.jsx'
-import TichuGame from '../tichu_game'
-import { GameSystem } from '../../api/games/logic.js'
+import { Game, Games } from '../../api/games/games'
+import PlayerEntry from '../components/player_entry'
+import { GameSystem } from '../../api/games/logic'
+import { GameApplication } from '../tichu_game'
 
-class Game extends React.Component {
-  renderGame () {
+class GameContainer extends React.Component<{ game: Game }> {
+  private game: GameApplication | null
+  private readonly gameRef: React.RefObject<HTMLDivElement>
+
+  constructor (props: Readonly<{ game: Game }>) {
+    super(props)
+    this.gameRef = React.createRef()
+  }
+
+  renderGame (): void {
+    if (this.gameRef.current == null) return
     if (this.game == null) {
-      this.game = new TichuGame()
+      this.game = new GameApplication(this.gameRef.current)
+    }
+    let p = this.props.game.players.find(p => p.id === Meteor.userId())
+    if (p != null) {
+      this.game.update(this.props.game, p.position)
     }
-    this.game.update(this.props.game, this.props.game.players.find(p => p.id === Meteor.userId()).position)
+  }
+
+  componentDidMount () {
+    this.renderGame()
   }
 
   render () {
     if (this.props.game != null && this.props.game.players.length === 4) {
       this.renderGame()
       return (<div>
+        <div ref={this.gameRef}/>
         <table>
           <thead>
           <tr>
@@ -55,15 +72,9 @@ class Game extends React.Component {
 }
 
 export default withTracker(function ({match}) {
-  if (this.called == null) {
-    joinGame.call({_id: match.params.id}, (err, res) => {
-      if (err) console.log('Error while joining game:', err)
-    })
-    this.called = true
-  }
-  let game = Games.find({_id: match.params.id}).fetch()[0]
+  let game: any = Games.find({_id: match.params.id}).fetch()[0]
   if (game != null) {
     game.sys = GameSystem.fromObject(game.sys)
   }
   return {game}
-})(Game)
+})(GameContainer)
diff --git a/ui/pages/game_list.tsx b/ui/pages/game_list.tsx
index 55877468f65d6cb9f11637731bf9e90ca5eebee8..aedb832ff26aef3668c1e7d79949162c06b2bb6b 100644
--- a/ui/pages/game_list.tsx
+++ b/ui/pages/game_list.tsx
@@ -1,13 +1,13 @@
 import * as React from 'react'
 import { withTracker } from 'meteor/react-meteor-data'
 import GameEntry from '../components/game_entry'
-import { Games, newGame } from '../../api/games/games'
+import { Game, Games, newGame } from '../../api/games/games'
 
 interface SubmitEvent extends Event {
   target: HTMLFormElement
 }
 
-class GameList extends React.Component<{ games: Array<{}> }> {
+class GameList extends React.Component<{ games: Array<Game> }> {
   onSubmit (event: SubmitEvent) {
     event.preventDefault()
 
diff --git a/startup/routes.tsx b/ui/routes.tsx
similarity index 80%
rename from startup/routes.tsx
rename to ui/routes.tsx
index f1f8e016490188a9b02c090e7623cc6c0a34099c..63a25923f71a0df11826ae1c4f5cee19eb14e24f 100644
--- a/startup/routes.tsx
+++ b/ui/routes.tsx
@@ -2,9 +2,9 @@ import * as React from 'react'
 import { Route, Router, Switch } from 'react-router'
 import createBrowserHistory from 'history/createBrowserHistory'
 
-import GameListPage from '../ui/pages/game_list'
-import GamePage from '../ui/pages/game.jsx'
-import IndexPage from '../ui/pages/index'
+import GameListPage from './pages/game_list'
+import GamePage from './pages/game.jsx'
+import IndexPage from './pages/index'
 
 const browserHistory = createBrowserHistory()
 
diff --git a/ui/tichu_game.js b/ui/tichu_game.tests.js
similarity index 94%
rename from ui/tichu_game.js
rename to ui/tichu_game.tests.js
index 576483d44ff89d33ae9f04e2843b2e72ff3b5287..68511d38cfaedf1cd32e97c7aecdee3b093c5903 100644
--- a/ui/tichu_game.js
+++ b/ui/tichu_game.tests.js
@@ -7,30 +7,7 @@ import {
   skipTurn
 } from '../api/games/games'
 import { Bet, GamePov } from '../api/games/logic'
-import { CardSet, Color, Trick } from '../api/games/cards'
-
-function cardName ({value, color}) {
-  switch (color) {
-    case Color.DRAGON:
-      return 'dragon'
-    case Color.PHOENIX:
-      return 'phoenix'
-    case Color.MAHJONG:
-      return 'mahjong'
-    case Color.DOG:
-      return 'dog'
-    case Color.BLACK:
-      return 'black' + value
-    case Color.BLUE:
-      return 'blue' + value
-    case Color.GREEN:
-      return 'green' + value
-    case Color.RED:
-      return 'red' + value
-    default:
-      return ''
-  }
-}
+import { CardSet, Color, Trick } from '../api/games/logic'
 
 function playerBackground (data, player) {
   if (data.givingDragon) return {backgroundColor: '#55c'}
diff --git a/ui/tichu_game.ts b/ui/tichu_game.ts
new file mode 100644
index 0000000000000000000000000000000000000000..53ebdcc20df2acc6c00c9e7c55d9ab3b95d0e708
--- /dev/null
+++ b/ui/tichu_game.ts
@@ -0,0 +1 @@
+export * from './_tichu_game/GameApplication'