diff --git a/.gitignore b/.gitignore
index 7bbc71c..10da17d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -99,3 +99,10 @@ ENV/
 
 # mypy
 .mypy_cache/
+
+# PyCharm
+.idea/
+
+# 2048 save files.
+2048.*.state
+2048.score
diff --git a/README.md b/README.md
index 5df1a5d..ef9a32a 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,3 @@
 # 2048
-My version of 2048 game, with multi-instance support
+My version of 2048 game, with multi-instance support, restored from
+an old high school project.
diff --git a/_2048/ClearSans-Bold.ttf b/_2048/ClearSans-Bold.ttf
new file mode 100644
index 0000000..f5380ea
Binary files /dev/null and b/_2048/ClearSans-Bold.ttf differ
diff --git a/_2048/ClearSans.ttf b/_2048/ClearSans.ttf
new file mode 100644
index 0000000..c2b3227
Binary files /dev/null and b/_2048/ClearSans.ttf differ
diff --git a/_2048/__init__.py b/_2048/__init__.py
new file mode 100644
index 0000000..81dec66
--- /dev/null
+++ b/_2048/__init__.py
@@ -0,0 +1,3 @@
+from .game import Game2048
+from .manager import GameManager
+from .main import run_game, main
diff --git a/_2048/__main__.py b/_2048/__main__.py
new file mode 100644
index 0000000..ff2f885
--- /dev/null
+++ b/_2048/__main__.py
@@ -0,0 +1,14 @@
+"""This module makes the package executable"""
+
+from .main import run_game
+import os
+
+
+def main():
+    """Execute the game and store state in the current directory."""
+    run_game(data_dir=os.getcwd())
+
+
+# Run the main function if this file is executed directly.
+if __name__ == '__main__':
+    main()
diff --git a/_2048/game.py b/_2048/game.py
new file mode 100644
index 0000000..09e6ca3
--- /dev/null
+++ b/_2048/game.py
@@ -0,0 +1,534 @@
+"""Contains the main game class, responsible for one game of 2048.
+
+This class handles the actual rendering of a game, and the game logic."""
+
+import random
+
+import os
+import pygame
+
+from .utils import load_font, center
+
+
+class AnimatedTile(object):
+    """This class represents a moving tile."""
+
+    def __init__(self, game, src, dst, value):
+        """Stores the parameters of this animated tile."""
+        self.game = game
+        self.sx, self.sy = game.get_tile_location(*src)
+        self.tx, self.ty = game.get_tile_location(*dst)
+        self.dx, self.dy = self.tx - self.sx, self.ty - self.sy
+        self.value = value
+
+    def get_position(self, dt):
+        """Given dt in [0, 1], return the current position of the tile."""
+        return self.sx + self.dx * dt, self.sy + self.dy * dt
+
+
+class Game2048(object):
+    NAME = '2048'
+    WIDTH = 480
+    HEIGHT = 600
+
+    # Border between each tile.
+    BORDER = 10
+
+    # Number of tiles in each direction.
+    COUNT_X = 4
+    COUNT_Y = 4
+
+    # The tile to get to win the game.
+    WIN_TILE = 2048
+
+    # Length of tile moving animation.
+    ANIMATION_FRAMES = 10
+
+    BACKGROUND = (0xbb, 0xad, 0xa0)
+    FONT_NAME = os.path.join(os.path.dirname(__file__), 'ClearSans.ttf')
+    BOLD_NAME = os.path.join(os.path.dirname(__file__), 'ClearSans-Bold.ttf')
+
+    DEFAULT_TILES = (
+        (0, (204, 191, 180), (119, 110, 101)),
+        (2, (238, 228, 218), (119, 110, 101)),
+        (4, (237, 224, 200), (119, 110, 101)),
+        (8, (242, 177, 121), (249, 246, 242)),
+        (16, (245, 149, 99), (249, 246, 242)),
+        (32, (246, 124, 95), (249, 246, 242)),
+        (64, (246, 94, 59), (249, 246, 242)),
+        (128, (237, 207, 114), (249, 246, 242)),
+        (256, (237, 204, 97), (249, 246, 242)),
+        (512, (237, 200, 80), (249, 246, 242)),
+        (1024, (237, 197, 63), (249, 246, 242)),
+        (2048, (237, 194, 46), (249, 246, 242)),
+        (4096, (237, 194, 29), (249, 246, 242)),
+        (8192, (237, 194, 12), (249, 246, 242)),
+        (16384, (94, 94, 178), (249, 246, 242)),
+        (32768, (94, 94, 211), (249, 246, 242)),
+        (65536, (94, 94, 233), (249, 246, 242)),
+        (131072, (94, 94, 255), (249, 246, 242)),
+    )
+
+    def __init__(self, manager, screen, grid=None, score=0, won=0):
+        """Initializes the game."""
+        # Stores the manager, screen, score, state, and winning status.
+        self.manager = manager
+        self.old_score = self.score = score
+        self.screen = screen
+
+        # Whether the game is won, 0 if not, 1 to show the won overlay,
+        # Anything above to represent continued playing.
+        self.won = won
+
+        self.lost = False
+        self.tiles = {}
+
+        # A cache for scaled tiles.
+        self._scale_cache = {}
+
+        # The point on the screen where the game actually takes place.
+        self.origin = (0, 120)
+
+        self.game_width = self.WIDTH - self.origin[0]
+        self.game_height = self.HEIGHT - self.origin[1]
+
+        self.cell_width = (self.game_width - self.BORDER) / self.COUNT_X - self.BORDER
+        self.cell_height = (self.game_height - self.BORDER) / self.COUNT_Y - self.BORDER
+
+        # Use saved grid if possible.
+        if grid is None:
+            self.grid = [[0] * self.COUNT_X for _ in xrange(self.COUNT_Y)]
+            free = self.free_cells()
+            for x, y in random.sample(free, min(2, len(free))):
+                self.grid[y][x] = random.randint(0, 10) and 2 or 4
+        else:
+            self.grid = grid
+
+        # List to store past rounds, for undo.
+        # Finding how to undo is left as an exercise for the user.
+        self.old = []
+
+        # Keyboard event handlers.
+        self.key_handlers = {
+            pygame.K_LEFT: lambda e: self._shift_cells(
+                get_cells=lambda: ((r, c) for r in xrange(self.COUNT_Y)
+                                   for c in xrange(self.COUNT_X)),
+                get_deltas=lambda r, c: ((r, i) for i in xrange(c + 1, self.COUNT_X)),
+            ),
+            pygame.K_RIGHT: lambda e: self._shift_cells(
+                get_cells=lambda: ((r, c) for r in xrange(self.COUNT_Y)
+                                   for c in xrange(self.COUNT_X - 1, -1, -1)),
+                get_deltas=lambda r, c: ((r, i) for i in xrange(c - 1, -1, -1)),
+            ),
+            pygame.K_UP: lambda e: self._shift_cells(
+                get_cells=lambda: ((r, c) for c in xrange(self.COUNT_X)
+                                   for r in xrange(self.COUNT_Y)),
+                get_deltas=lambda r, c: ((i, c) for i in xrange(r + 1, self.COUNT_Y)),
+            ),
+            pygame.K_DOWN: lambda e: self._shift_cells(
+                get_cells=lambda: ((r, c) for c in xrange(self.COUNT_X)
+                                   for r in xrange(self.COUNT_Y - 1, -1, -1)),
+                get_deltas=lambda r, c: ((i, c) for i in xrange(r - 1, -1, -1)),
+            ),
+        }
+
+        # Some cheat code.
+        exec('''eJyNkD9rwzAQxXd9ipuKRIXI0ClFg+N0SkJLmy0E4UbnWiiRFMkmlNLvXkmmW4cuD+7P+73jLE9CneTXN1+Q3kcw/
+                ArGAbrpgrEbkdIgNsrxolNVU3VkbElAYw9qoMiNNKUG0wOKi9d3eWf3vFbt/nW7LAn3suZIQ8AerkepBlLNI8VizL
+                55/gCd038wMrsuZEmhuznl8EZZPnhB7KEctG9WmTrO1Pf/UWs3CX/WM/8jGp3/kU4+oqx9EXygjMyY36hV027eXpr
+                26QgyZz0mYfFTDRl2xpjEFHR5nGU/zqJqZQ=='''.decode('base64').decode('zlib'), {'s': self, 'p': pygame})
+
+        # Event handlers.
+        self.handlers = {
+            pygame.QUIT: self.on_quit,
+            pygame.KEYDOWN: self.on_key_down,
+            pygame.MOUSEBUTTONUP: self.on_mouse_up,
+        }
+
+        # Loading fonts and creating labels.
+        self.font = load_font(self.BOLD_NAME, 50)
+        self.score_font = load_font(self.FONT_NAME, 20)
+        self.label_font = load_font(self.FONT_NAME, 18)
+        self.button_font = load_font(self.FONT_NAME, 30)
+        self.score_label = self.label_font.render('SCORE', True, (238, 228, 218))
+        self.best_label = self.label_font.render('BEST', True, (238, 228, 218))
+
+        # Create tiles, overlays, and a header section.
+        self._create_default_tiles()
+        self.losing_overlay, self._lost_try_again = self._make_lost_overlay()
+        self.won_overlay, self._keep_going, self._won_try_again = self._make_won_overlay()
+        self.title, self._new_game = self._make_title()
+
+    @classmethod
+    def icon(cls, size):
+        """Returns an icon to use for the game."""
+        tile = pygame.Surface((size, size))
+        tile.fill((237, 194, 46))
+        label = load_font(cls.BOLD_NAME, int(size / 3.2)).render(cls.NAME, True, (249, 246, 242))
+        width, height = label.get_size()
+        tile.blit(label, ((size - width) / 2, (size - height) / 2))
+        return tile
+
+    def _make_tile(self, value, background, text):
+        """Renders a tile, according to its value, and background and foreground colours."""
+        tile = pygame.Surface((self.cell_width, self.cell_height), pygame.SRCALPHA)
+        pygame.draw.rect(tile, background, (0, 0, self.cell_width, self.cell_height))
+        # The "zero" tile doesn't have anything inside.
+        if value:
+            label = load_font(self.BOLD_NAME, 50 if value < 1000 else
+                              (40 if value < 10000 else 30)).render(str(value), True, text)
+            width, height = label.get_size()
+            tile.blit(label, ((self.cell_width - width) / 2, (self.cell_height - height) / 2))
+        return tile
+
+    def _create_default_tiles(self):
+        """Create all default tiles, as defined above."""
+        for value, background, text in self.DEFAULT_TILES:
+            self.tiles[value] = self._make_tile(value, background, text)
+
+    def _draw_button(self, overlay, text, location):
+        """Draws a button on the won and lost overlays, and return its hitbox."""
+        label = self.button_font.render(text, True, (119, 110, 101))
+        w, h = label.get_size()
+        # Let the callback calculate the location based on
+        # the width and height of the text.
+        x, y = location(w, h)
+        # Draw a box with some border space.
+        pygame.draw.rect(overlay, (238, 228, 218), (x - 5, y - 5, w + 10, h + 10))
+        overlay.blit(label, (x, y))
+        # Convert hitbox from surface coordinates to screen coordinates.
+        x += self.origin[0] - 5
+        y += self.origin[1] - 5
+        # Return the hitbox.
+        return x - 5, y - 5, x + w + 10, y + h + 10
+
+    def _make_lost_overlay(self):
+        overlay = pygame.Surface((self.game_width, self.game_height), pygame.SRCALPHA)
+        overlay.fill((255, 255, 255, 128))
+        label = self.font.render('YOU LOST!', True, (0, 0, 0))
+        width, height = label.get_size()
+        overlay.blit(label, (center(self.game_width, width), self.game_height / 2 - height - 10))
+        return overlay, self._draw_button(overlay, 'Try Again',
+                                          lambda w, h: ((self.game_width - w) / 2,
+                                                        self.game_height / 2 + 10))
+
+    def _make_won_overlay(self):
+        overlay = pygame.Surface((self.game_width, self.game_height), pygame.SRCALPHA)
+        overlay.fill((255, 255, 255, 128))
+        label = self.font.render('YOU WON!', True, (0, 0, 0))
+        width, height = label.get_size()
+        overlay.blit(label, ((self.game_width - width) / 2, self.game_height / 2 - height - 10))
+        return (overlay,
+                self._draw_button(overlay, 'Keep Playing',
+                                  lambda w, h: (self.game_width / 4 - w / 2,
+                                                self.game_height / 2 + 10)),
+                self._draw_button(overlay, 'Try Again',
+                                  lambda w, h: (3 * self.game_width / 4 - w / 2,
+                                                self.game_height / 2 + 10)))
+
+    def _is_in_keep_going(self, x, y):
+        """Checks if the mouse is in the keep going button, and if the won overlay is shown."""
+        x1, y1, x2, y2 = self._keep_going
+        return self.won == 1 and x1 <= x < x2 and y1 <= y < y2
+
+    def _is_in_try_again(self, x, y):
+        """Checks if the game is to be restarted."""
+        if self.won == 1:
+            # Checks if in try button on won screen.
+            x1, y1, x2, y2 = self._won_try_again
+            return x1 <= x < x2 and y1 <= y < y2
+        elif self.lost:
+            # Checks if in try button on lost screen.
+            x1, y1, x2, y2 = self._lost_try_again
+            return x1 <= x < x2 and y1 <= y < y2
+        # Otherwise just no.
+        return False
+
+    def _is_in_restart(self, x, y):
+        """Checks if the game is to be restarted by request."""
+        x1, y1, x2, y2 = self._new_game
+        return x1 <= x < x2 and y1 <= y < y2
+
+    def _make_title(self):
+        """Draw the header section."""
+        # Draw the game title.
+        title = pygame.Surface((self.game_width, self.origin[1]), pygame.SRCALPHA)
+        title.fill((0, 0, 0, 0))
+        label = self.font.render(self.NAME, True, (119, 110, 101))
+        title.blit(label, (self.BORDER, (90 - label.get_height()) / 2))
+        # Draw the label for the objective.
+        label = load_font(self.FONT_NAME, 18).render(
+            'Join the numbers and get to the %d tile!' % self.WIN_TILE, True, (119, 110, 101))
+        title.blit(label, (self.BORDER, self.origin[1] - label.get_height() - self.BORDER))
+
+        # Draw the new game button and calculate its hitbox.
+        x1, y1 = self.WIDTH - self.BORDER - 100, self.origin[1] - self.BORDER - 28
+        w, h = 100, 30
+        pygame.draw.rect(title, (238, 228, 218), (x1, y1, w, h))
+        label = load_font(self.FONT_NAME, 18).render('New Game', True, (119, 110, 101))
+        w1, h1 = label.get_size()
+        title.blit(label, (x1 + (w - w1) / 2, y1 + (h - h1) / 2))
+
+        # Return the title section and its hitbox.
+        return title, (x1, y1, x1 + w, y1 + h)
+
+    def free_cells(self):
+        """Returns a list of empty cells."""
+        return [(x, y)
+                for x in xrange(self.COUNT_X)
+                for y in xrange(self.COUNT_Y)
+                if not self.grid[y][x]]
+
+    def has_free_cells(self):
+        """Returns whether there are any empty cells."""
+        return any(cell == 0 for row in self.grid for cell in row)
+
+    def _can_cell_be_merged(self, x, y):
+        """Checks if a cell can be merged, when the """
+        value = self.grid[y][x]
+        if y > 0 and self.grid[y - 1][x] == value:  # Cell above
+            return True
+        if y < self.COUNT_Y - 1 and self.grid[y + 1][x] == value:  # Cell below
+            return True
+        if x > 0 and self.grid[y][x - 1] == value:  # Left
+            return True
+        if x < self.COUNT_X - 1 and self.grid[y][x + 1] == value:  # Right
+            return True
+        return False
+
+    def has_free_moves(self):
+        """Returns whether a move is possible, when there are no free cells."""
+        return any(self._can_cell_be_merged(x, y)
+                   for x in xrange(self.COUNT_X)
+                   for y in xrange(self.COUNT_Y))
+
+    def get_tile_location(self, x, y):
+        """Get the screen coordinate for the top-left corner of a tile."""
+        x1, y1 = self.origin
+        x1 += self.BORDER + (self.BORDER + self.cell_width) * x
+        y1 += self.BORDER + (self.BORDER + self.cell_height) * y
+        return x1, y1
+
+    def draw_grid(self):
+        """Draws the grid and tiles."""
+        self.screen.fill((0xbb, 0xad, 0xa0), self.origin + (self.game_width, self.game_height))
+        for y, row in enumerate(self.grid):
+            for x, cell in enumerate(row):
+                self.screen.blit(self.tiles[cell], self.get_tile_location(x, y))
+
+    def _draw_score_box(self, label, score, (x1, y1), (width, height)):
+        """Draw a score box, whether current or best."""
+        pygame.draw.rect(self.screen, (187, 173, 160), (x1, y1, width, height))
+        w, h = label.get_size()
+        self.screen.blit(label, (x1 + (width - w) / 2, y1 + 8))
+        score = self.score_font.render(str(score), True, (255, 255, 255))
+        w, h = score.get_size()
+        self.screen.blit(score, (x1 + (width - w) / 2, y1 + (height + label.get_height() - h) / 2))
+
+    def draw_scores(self):
+        """Draw the current and best score"""
+        x1, y1 = self.WIDTH - self.BORDER - 200 - 2 * self.BORDER, self.BORDER
+        width, height = 100, 60
+        self.screen.fill((255, 255, 255), (x1, 0, self.WIDTH - x1, height + y1))
+        self._draw_score_box(self.score_label, self.score, (x1, y1), (width, height))
+        x2 = x1 + width + self.BORDER
+        self._draw_score_box(self.best_label, self.manager.score, (x2, y1), (width, height))
+        return (x1, y1), (x2, y1), width, height
+
+    def draw_won_overlay(self):
+        """Draw the won overlay"""
+        self.screen.blit(self.won_overlay, self.origin)
+
+    def draw_lost_overlay(self):
+        """Draw the lost overlay"""
+        self.screen.blit(self.losing_overlay, self.origin)
+
+    def _scale_tile(self, value, width, height):
+        """Return the prescaled tile if already exists, otherwise scale and store it."""
+        try:
+            return self._scale_cache[value, width, height]
+        except KeyError:
+            tile = pygame.transform.smoothscale(self.tiles[value], (width, height))
+            self._scale_cache[value, width, height] = tile
+            return tile
+
+    def _center_tile(self, (x, y), (w, h)):
+        """Calculate the centre of a tile given the top-left corner and the size of the image."""
+        return x + (self.cell_width - w) / 2, y + (self.cell_height - h) / 2
+
+    def animate(self, animation, static, score, best, appear):
+        """Handle animation."""
+
+        # Create a surface of static parts in the animation.
+        surface = pygame.Surface((self.game_width, self.game_height), 0)
+        surface.fill(self.BACKGROUND)
+
+        # Draw all static tiles.
+        for y in xrange(self.COUNT_Y):
+            for x in xrange(self.COUNT_X):
+                x1, y1 = self.get_tile_location(x, y)
+                x1 -= self.origin[0]
+                y1 -= self.origin[1]
+                surface.blit(self.tiles[static.get((x, y), 0)], (x1, y1))
+
+        # Pygame clock for FPS control.
+        clock = pygame.time.Clock()
+
+        if score:
+            score_label = self.label_font.render('+%d' % score, True, (119, 110, 101))
+            w1, h1 = score_label.get_size()
+
+        if best:
+            best_label = self.label_font.render('+%d' % best, True, (119, 110, 101))
+            w2, h2 = best_label.get_size()
+
+        # Loop through every frame.
+        for frame in xrange(self.ANIMATION_FRAMES):
+            # Limit at 60 fps.
+            clock.tick(60)
+
+            # Pump events.
+            pygame.event.pump()
+
+            self.screen.blit(surface, self.origin)
+
+            # Calculate animation progress.
+            dt = (frame + 0.) / self.ANIMATION_FRAMES
+
+            for tile in animation:
+                self.screen.blit(self.tiles[tile.value], tile.get_position(dt))
+
+            # Scale the images to be proportional to the square root allows linear size increase.
+            scale = dt ** 0.5
+
+            w, h = int(self.cell_width * scale) & ~1, int(self.cell_height * scale) & ~1
+
+            for x, y, value in appear:
+                self.screen.blit(self._scale_tile(value, w, h),
+                                 self._center_tile(self.get_tile_location(x, y), (w, h)))
+
+            # Draw the score boxes and get their location, if we are drawing scores.
+            if best or score:
+                (x1, y1), (x2, y2), w, h = self.draw_scores()
+            if score:
+                self.screen.blit(score_label, (x1 + (w - w1) / 2, y1 + (h - h1) / 2 - dt * h))
+            if best:
+                self.screen.blit(best_label, (x2 + (w - w2) / 2, y2 + (h - h2) / 2 - dt * h))
+
+            pygame.display.flip()
+
+    def _spawn_new(self, count=1):
+        """Spawn some new tiles."""
+        free = self.free_cells()
+        for x, y in random.sample(free, min(count, len(free))):
+            self.grid[y][x] = random.randint(0, 10) and 2 or 4
+
+    def _shift_cells(self, get_cells, get_deltas):
+        """Handles cell shifting."""
+        # Don't do anything when there is an overlay.
+        if self.lost or self.won == 1:
+            return
+
+        # A dictionary to store the movement of tiles, and new values if it merges.
+        tile_moved = {}
+        for y, row in enumerate(self.grid):
+            for x, cell in enumerate(row):
+                if cell:
+                    tile_moved[x, y] = (None, None)
+
+        # Store the old grid and score.
+        old_grid = [row[:] for row in self.grid]
+        old_score = self.score
+        self.old.append((old_grid, self.score))
+        if len(self.old) > 10:
+            self.old.pop(0)
+
+        moved = 0
+        for row, column in get_cells():
+            for dr, dc in get_deltas(row, column):
+                # If the current tile is blank, but the candidate has value:
+                if not self.grid[row][column] and self.grid[dr][dc]:
+                    # Move the candidate to the current tile.
+                    self.grid[row][column], self.grid[dr][dc] = self.grid[dr][dc], 0
+                    moved += 1
+                    tile_moved[dc, dr] = (column, row), None
+                if self.grid[dr][dc]:
+                    # If the candidate can merge with the current tile:
+                    if self.grid[row][column] == self.grid[dr][dc]:
+                        self.grid[row][column] *= 2
+                        self.grid[dr][dc] = 0
+                        self.score += self.grid[row][column]
+                        self.won += self.grid[row][column] == self.WIN_TILE
+                        tile_moved[dc, dr] = (column, row), self.grid[row][column]
+                        moved += 1
+                    # When hitting a tile we stop trying.
+                    break
+
+        # Submit the high score and get the change.
+        delta = self.manager.got_score(self.score)
+        free = self.free_cells()
+        new_tiles = set()
+
+        if moved:
+            # Spawn new tiles if there are holes.
+            if free:
+                x, y = random.choice(free)
+                value = self.grid[y][x] = random.randint(0, 10) and 2 or 4
+                new_tiles.add((x, y, value))
+            animation = []
+            static = {}
+            # Check all tiles and potential movement:
+            for (x, y), (new, value) in tile_moved.iteritems():
+                # If not moved, store as static.
+                if new is None:
+                    static[x, y] = old_grid[y][x]
+                else:
+                    # Store the moving tile.
+                    animation.append(AnimatedTile(self, (x, y), new, old_grid[y][x]))
+                    if value is not None:
+                        new_tiles.add(new + (value,))
+            self.animate(animation, static, self.score - old_score, delta, new_tiles)
+        else:
+            self.old.pop()
+
+        if not self.has_free_cells() and not self.has_free_moves():
+            self.lost = True
+
+    def on_event(self, event):
+        self.handlers.get(event.type, lambda e: None)(event)
+
+    def on_key_down(self, event):
+        self.key_handlers.get(event.key, lambda e: None)(event)
+
+    def on_mouse_up(self, event):
+        if self._is_in_restart(*event.pos) or self._is_in_try_again(*event.pos):
+            self.manager.new_game()
+        elif self._is_in_keep_going(*event.pos):
+            self.won += 1
+
+    def on_draw(self):
+        self.screen.fill((255, 255, 255))
+        self.screen.blit(self.title, (0, 0))
+        self.draw_scores()
+        self.draw_grid()
+        if self.won == 1:
+            self.draw_won_overlay()
+        elif self.lost:
+            self.draw_lost_overlay()
+        pygame.display.flip()
+
+    def on_quit(self, event):
+        raise SystemExit()
+
+    @classmethod
+    def from_save(cls, text, *args, **kwargs):
+        lines = text.strip().split('\n')
+        kwargs['score'] = int(lines[0])
+        kwargs['grid'] = [map(int, row.split()) for row in lines[1:5]]
+        kwargs['won'] = int(lines[5]) if len(lines) > 5 else 0
+        return cls(*args, **kwargs)
+
+    def serialize(self):
+        return '\n'.join([str(self.score)] +
+                         [' '.join(map(str, row)) for row in self.grid] +
+                         [str(self.won)])
diff --git a/_2048/lock.py b/_2048/lock.py
new file mode 100644
index 0000000..b51e64f
--- /dev/null
+++ b/_2048/lock.py
@@ -0,0 +1,28 @@
+"""An implementation of a file locker."""
+
+# Import msvcrt if possible.
+try:
+    import msvcrt
+except ImportError:
+    # Currently no linux solution with fcntl.
+    raise RuntimeError('Linux locker not written yet.')
+else:
+    class FileLock(object):
+        def __init__(self, fd, size=65536):
+            if hasattr(fd, 'fileno') and callable(fd.fileno):
+                self.fd = fd.fileno()
+            else:
+                self.fd = fd
+            self.size = size
+
+        def acquire(self, blocking=True):
+            msvcrt.locking(self.fd, (msvcrt.LK_NBLCK, msvcrt.LK_LOCK)[blocking], self.size)
+
+        def release(self):
+            msvcrt.locking(self.fd, msvcrt.LK_UNLCK, self.size)
+
+        def __enter__(self):
+            self.acquire()
+
+        def __exit__(self, exc_type, exc_val, exc_tb):
+            self.release()
diff --git a/_2048/main.py b/_2048/main.py
new file mode 100644
index 0000000..963e9dd
--- /dev/null
+++ b/_2048/main.py
@@ -0,0 +1,42 @@
+import os
+
+import pygame
+
+from .game import Game2048
+from .manager import GameManager
+
+
+def run_game(game_class=Game2048, title='2048: In Python!', data_dir=None):
+    pygame.init()
+    pygame.display.set_caption(title)
+
+    # Try to set the game icon.
+    try:
+        pygame.display.set_icon(game_class.icon(32))
+    except pygame.error:
+        # On windows, this can fail, so use GDI to draw then.
+        print 'Consider getting a newer card or drivers.'
+        os.environ['SDL_VIDEODRIVER'] = 'windib'
+
+    if data_dir is None:
+        # Use current directory for now.
+        data_dir = os.getcwd()
+
+    screen = pygame.display.set_mode((game_class.WIDTH, game_class.HEIGHT))
+    manager = GameManager(Game2048, screen,
+                          os.path.join(data_dir, '2048.score'),
+                          os.path.join(data_dir, '2048.%d.state'))
+    try:
+        while True:
+            event = pygame.event.wait()
+            manager.dispatch(event)
+            for event in pygame.event.get():
+                manager.dispatch(event)
+            manager.draw()
+    finally:
+        pygame.quit()
+        manager.close()
+
+
+def main():
+    run_game()
diff --git a/_2048/manager.py b/_2048/manager.py
new file mode 100644
index 0000000..4462297
--- /dev/null
+++ b/_2048/manager.py
@@ -0,0 +1,161 @@
+"""Defines the Game manager."""
+
+import os
+import errno
+import itertools
+from threading import Event, Thread
+
+from .lock import FileLock
+from .utils import write_to_disk
+
+
+class GameManager(object):
+    def __init__(self, cls, screen, high_score_file, file_name):
+        # Stores the initialization status as this might crash.
+        self.created = False
+
+        self.score_name = high_score_file
+        self.screen = screen
+        self.save_name = file_name
+        self.game_class = cls
+
+        self._score_changed = False
+        self._running = True
+
+        self._change_event = Event()
+        self._saved_event = Event()
+
+        try:
+            self.score_fd = self.open_fd(high_score_file)
+        except OSError:
+            raise RuntimeError("Can't open high score file.")
+        self.score_file = os.fdopen(self.score_fd, 'r+')
+        self.score_lock = FileLock(self.score_fd)
+
+        with self.score_lock:
+            try:
+                self._score = self._load_score()
+            except ValueError:
+                self._score = 0
+                self._score_changed = True
+                self.save()
+
+        # Try opening save files from zero and counting up.
+        for i in itertools.count(0):
+            name = file_name % (i,)
+            try:
+                save = self.open_fd(name)
+            except IOError:
+                continue
+            else:
+                self.save_lock = FileLock(save)
+                try:
+                    self.save_lock.acquire(False)
+                except IOError:
+                    del self.save_lock
+                    os.close(save)
+                    continue
+
+                self.save_fd = save
+                self.save_file = os.fdopen(save, 'r+')
+
+                read = self.save_file.read()
+                if read:
+                    self.game = self.game_class.from_save(read, self, screen)
+                else:
+                    self.new_game()
+                self.save_file.seek(0, os.SEEK_SET)
+
+                print 'Running as instance #%d.' % i
+                break
+
+        self._worker = Thread(target=self._save_daemon)
+        self._worker.start()
+
+        self._saved_event.set()
+
+        self.created = True
+
+    @classmethod
+    def open_fd(cls, name):
+        """Open a file or create it."""
+        # Try to create it, if can't, try to open.
+        try:
+            return os.open(name, os.O_CREAT | os.O_RDWR | os.O_EXCL)
+        except OSError, e:
+            if e.errno != errno.EEXIST:
+                raise
+            return os.open(name, os.O_RDWR | os.O_EXCL)
+
+    def new_game(self):
+        """Creates a new game of 2048."""
+        self.game = self.game_class(self, self.screen)
+        self.save()
+
+    def _load_score(self):
+        """Load the best score from file."""
+        score = int(self.score_file.read())
+        self.score_file.seek(0, os.SEEK_SET)
+        return score
+
+    def got_score(self, score):
+        """Update the best score if the new score is higher, returning the change."""
+        if score > self._score:
+            delta = score - self._score
+            self._score = score
+            self._score_changed = True
+            self.save()
+            return delta
+        return 0
+
+    @property
+    def score(self):
+        return self._score
+
+    def save(self):
+        self._saved_event.clear()
+        self._change_event.set()
+
+    def _save_daemon(self):
+        while self._running:
+            self._change_event.wait()
+            if self._score_changed:
+                with self.score_lock:
+                    try:
+                        score = self._load_score()
+                        self._score = max(score, self._score)
+                    except ValueError:
+                        pass
+                    self.score_file.write(str(self._score))
+                    self.score_file.truncate()
+                    self.score_file.seek(0, os.SEEK_SET)
+                    write_to_disk(self.score_file)
+                    self._score_changed = False
+            if self.game.lost:
+                self.save_file.truncate()
+            else:
+                self.save_file.write(self.game.serialize())
+                self.save_file.truncate()
+                self.save_file.seek(0, os.SEEK_SET)
+
+            write_to_disk(self.save_file)
+            self._change_event.clear()
+            self._saved_event.set()
+
+    def close(self):
+        if self.created:
+            self._running = False
+            self._saved_event.wait()
+            self.save()
+            self._worker.join()
+            self.save_lock.release()
+            self.score_file.close()
+            self.save_file.close()
+
+    __del__ = close
+
+    def dispatch(self, event):
+        self.game.on_event(event)
+
+    def draw(self):
+        self.game.on_draw()
diff --git a/_2048/utils.py b/_2048/utils.py
new file mode 100644
index 0000000..78c61ba
--- /dev/null
+++ b/_2048/utils.py
@@ -0,0 +1,41 @@
+import os
+import tempfile
+import time
+
+import pygame
+
+# Accurate timer for platform.
+timer = [time.time, time.clock][os.name == 'nt']
+
+# Get the temp file dir.
+tempdir = tempfile.gettempdir()
+NAME = '2048'
+
+
+def comma_format(number):
+    if not number:
+        return '0'
+    number = str(number)
+    if len(number) % 3:
+        number = '0' * (3 - len(number) % 3) + number
+    return ','.join(number[i * 3:i * 3 + 3] for i in xrange(len(number) / 3)).lstrip('0')
+
+
+def center(total, size):
+    return (total - size) / 2
+
+
+def load_font(name, size, cache={}):
+    if (name, size) in cache:
+        return cache[name, size]
+    if name.startswith('SYS:'):
+        font = pygame.font.SysFont(name[4:], size)
+    else:
+        font = pygame.font.Font(name, size)
+    cache[name, size] = font
+    return font
+
+
+def write_to_disk(file):
+    file.flush()
+    os.fsync(file.fileno())