├── requirements.txt ├── MANIFEST.in ├── gym_2048 ├── __init__.py └── env.py ├── .travis.yml ├── LICENSE ├── setup.py ├── .gitignore ├── README.rst └── .pylintrc /requirements.txt: -------------------------------------------------------------------------------- 1 | gym~=0.10.0 2 | numpy~=1.14.0 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE VERSION README.rst requirements.txt 2 | -------------------------------------------------------------------------------- /gym_2048/__init__.py: -------------------------------------------------------------------------------- 1 | from gym.envs.registration import register 2 | from .env import Base2048Env 3 | 4 | register( 5 | id='Tiny2048-v0', 6 | entry_point='gym_2048.env:Base2048Env', 7 | kwargs={ 8 | 'width': 2, 9 | 'height': 2, 10 | } 11 | ) 12 | 13 | register( 14 | id='2048-v0', 15 | entry_point='gym_2048.env:Base2048Env', 16 | kwargs={ 17 | 'width': 4, 18 | 'height': 4, 19 | } 20 | ) 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | 5 | cache: 6 | pip: true 7 | 8 | stages: 9 | - name: lint 10 | if: (branch = master) OR (type = pull_request AND head_branch = master) 11 | # - name: test 12 | # if: (branch = master) OR (type = pull_request AND head_branch = master) 13 | - name: deploy 14 | if: tag IS present 15 | 16 | jobs: 17 | include: 18 | 19 | - stage: lint 20 | script: 21 | - pip install -U pylint 22 | - pylint gym_2048 23 | 24 | # - stage: test 25 | # script: 26 | # - pip install . 27 | # - echo "TODO!" 28 | 29 | - stage: deploy 30 | script: true 31 | before_deploy: 32 | - echo "$TRAVIS_TAG" > VERSION 33 | deploy: 34 | provider: pypi 35 | user: $PYPI_USERNAME 36 | password: $PYPI_PASSWORD 37 | skip_cleanup: true 38 | on: 39 | tags: true 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Sanyam Kapoor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | from setuptools import setup, find_packages 4 | 5 | CURRENT_PYTHON = sys.version_info[:2] 6 | MIN_PYTHON = (3, 6) 7 | 8 | if CURRENT_PYTHON < MIN_PYTHON: 9 | sys.stderr.write(""" 10 | ============================ 11 | Unsupported Python Version 12 | ============================ 13 | 14 | Python {}.{} is unsupported. Please use a version newer than Python {}.{}. 15 | """.format(*CURRENT_PYTHON, *MIN_PYTHON)) 16 | sys.exit(1) 17 | 18 | with open('requirements.txt', 'r') as f: 19 | install_requires = f.readlines() 20 | 21 | with open('README.rst') as f: 22 | README = f.read() 23 | 24 | if os.path.isfile('VERSION'): 25 | with open('VERSION') as f: 26 | VERSION = f.read() 27 | else: 28 | VERSION = os.environ.get('TRAVIS_PULL_REQUEST_BRANCH') or os.environ.get('TRAVIS_BRANCH') or 'dev' 29 | 30 | setup(name='gym-2048', 31 | description='OpenAI Gym Environment for 2048', 32 | long_description=README, 33 | long_description_content_type='text/x-rst', 34 | version=VERSION, 35 | url='https://www.github.com/activatedgeek/gym-2048', 36 | author='Sanyam Kapoor', 37 | license='MIT', 38 | packages=find_packages(), 39 | install_requires=install_requires, 40 | extras_require={}, 41 | ) 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Gym 2048 2 | ========= 3 | 4 | .. image:: https://travis-ci.com/salmanazarr/gym-2048.svg?branch=master 5 | :target: https://travis-ci.com/salmanazarr/gym-2048 6 | 7 | .. image:: https://badge.fury.io/py/gym-2048.svg 8 | :target: https://pypi.org/project/gym-2048/ 9 | 10 | This package implements the classic grid game 2048 11 | for OpenAI gym environment. 12 | 13 | Install 14 | -------- 15 | 16 | .. code:: shell 17 | 18 | pip install gym-2048 19 | 20 | Environment(s) 21 | --------------- 22 | 23 | The package currently contains two environments 24 | 25 | - ``Tiny2048-v0``: A ``2 x 2`` grid game. 26 | - ``2048-v0``: The standard ``4 x 4`` grid game. 27 | 28 | 29 | Attributes 30 | ^^^^^^^^^^^ 31 | 32 | - **Observation**: All observations are ``n x n`` numpy arrays 33 | representing the grid. The array is ``0`` for empty locations 34 | and numbered ``2, 4, 8, ...`` wherever the tiles are placed. 35 | 36 | - **Actions**: There are four actions defined by integers. 37 | - ``LEFT = 0`` 38 | - ``UP = 1`` 39 | - ``RIGHT = 2`` 40 | - ``DOWN = 3`` 41 | 42 | - **Reward**: Reward is the total score obtained by merging any 43 | potential tiles for a given action. Score obtained by merging 44 | two tiles is simply the sum of values of those two tiles. 45 | 46 | Rendering 47 | ^^^^^^^^^^ 48 | 49 | Currently only basic print rendering (``mode='human'``) is supported. 50 | Graphic rendering support is coming soon. 51 | 52 | Usage 53 | ------ 54 | 55 | Here is a sample rollout of the game which follows the same API as 56 | OpenAI ``gym.Env``. 57 | 58 | .. code:: python 59 | 60 | import gym_2048 61 | import gym 62 | 63 | 64 | if __name__ == '__main__': 65 | env = gym.make('2048-v0') 66 | env.seed(42) 67 | 68 | env.reset() 69 | env.render() 70 | 71 | done = False 72 | moves = 0 73 | while not done: 74 | action = env.np_random.choice(range(4), 1).item() 75 | next_state, reward, done, info = env.step(action) 76 | moves += 1 77 | 78 | print('Next Action: "{}"\n\nReward: {}'.format( 79 | gym_2048.Base2048Env.ACTION_STRING[action], reward)) 80 | env.render() 81 | 82 | print('\nTotal Moves: {}'.format(moves)) 83 | 84 | 85 | **NOTE**: Top level ``import gym_2048`` is needed to ensure registration with 86 | ``Gym``. 87 | -------------------------------------------------------------------------------- /gym_2048/env.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import gym 3 | import gym.spaces as spaces 4 | from gym.utils import seeding 5 | 6 | 7 | class Base2048Env(gym.Env): 8 | metadata = { 9 | 'render.modes': ['human'], 10 | } 11 | 12 | ## 13 | # NOTE: Don't modify these numbers as 14 | # they define the number of 15 | # anti-clockwise rotations before 16 | # applying the left action on a grid 17 | # 18 | LEFT = 0 19 | UP = 1 20 | RIGHT = 2 21 | DOWN = 3 22 | 23 | ACTION_STRING = { 24 | LEFT: 'left', 25 | UP: 'up', 26 | RIGHT: 'right', 27 | DOWN: 'down', 28 | } 29 | 30 | def __init__(self, width=4, height=4): 31 | self.width = width 32 | self.height = height 33 | 34 | self.observation_space = spaces.Box(low=2, 35 | high=2**32, 36 | shape=(self.width, self.height), 37 | dtype=np.int64) 38 | self.action_space = spaces.Discrete(4) 39 | 40 | # Internal Variables 41 | self.board = None 42 | self.np_random = None 43 | 44 | self.seed() 45 | self.reset() 46 | 47 | def seed(self, seed=None): 48 | self.np_random, seed = seeding.np_random(seed) 49 | return [seed] 50 | 51 | def step(self, action: int): 52 | """Rotate board aligned with left action""" 53 | 54 | # Align board action with left action 55 | rotated_obs = np.rot90(self.board, k=action) 56 | reward, updated_obs = self._slide_left_and_merge(rotated_obs) 57 | self.board = np.rot90(updated_obs, k=4 - action) 58 | 59 | # Place one random tile on empty location 60 | self._place_random_tiles(self.board, count=1) 61 | 62 | done = self.is_done() 63 | 64 | return self.board, reward, done, {} 65 | 66 | def is_done(self): 67 | copy_board = self.board.copy() 68 | 69 | if not copy_board.all(): 70 | return False 71 | 72 | for action in [0, 1, 2, 3]: 73 | rotated_obs = np.rot90(copy_board, k=action) 74 | _, updated_obs = self._slide_left_and_merge(rotated_obs) 75 | if not updated_obs.all(): 76 | return False 77 | 78 | return True 79 | 80 | 81 | def reset(self): 82 | """Place 2 tiles on empty board.""" 83 | 84 | self.board = np.zeros((self.width, self.height), dtype=np.int64) 85 | self._place_random_tiles(self.board, count=2) 86 | 87 | return self.board 88 | 89 | def render(self, mode='human'): 90 | if mode == 'human': 91 | for row in self.board.tolist(): 92 | print(' \t'.join(map(str, row))) 93 | 94 | def _sample_tiles(self, count=1): 95 | """Sample tile 2 or 4.""" 96 | 97 | choices = [2, 4] 98 | probs = [0.9, 0.1] 99 | 100 | tiles = self.np_random.choice(choices, 101 | size=count, 102 | p=probs) 103 | return tiles.tolist() 104 | 105 | def _sample_tile_locations(self, board, count=1): 106 | """Sample grid locations with no tile.""" 107 | 108 | zero_locs = np.argwhere(board == 0) 109 | zero_indices = self.np_random.choice( 110 | len(zero_locs), size=count) 111 | 112 | zero_pos = zero_locs[zero_indices] 113 | zero_pos = list(zip(*zero_pos)) 114 | return zero_pos 115 | 116 | def _place_random_tiles(self, board, count=1): 117 | if not board.all(): 118 | tiles = self._sample_tiles(count) 119 | tile_locs = self._sample_tile_locations(board, count) 120 | board[tile_locs] = tiles 121 | 122 | def _slide_left_and_merge(self, board): 123 | """Slide tiles on a grid to the left and merge.""" 124 | 125 | result = [] 126 | 127 | score = 0 128 | for row in board: 129 | row = np.extract(row > 0, row) 130 | score_, result_row = self._try_merge(row) 131 | score += score_ 132 | row = np.pad(np.array(result_row), (0, self.width - len(result_row)), 133 | 'constant', constant_values=(0,)) 134 | result.append(row) 135 | 136 | return score, np.array(result, dtype=np.int64) 137 | 138 | @staticmethod 139 | def _try_merge(row): 140 | score = 0 141 | result_row = [] 142 | 143 | i = 1 144 | while i < len(row): 145 | if row[i] == row[i - 1]: 146 | score += row[i] + row[i - 1] 147 | result_row.append(row[i] + row[i - 1]) 148 | i += 2 149 | else: 150 | result_row.append(row[i - 1]) 151 | i += 1 152 | 153 | if i == len(row): 154 | result_row.append(row[i - 1]) 155 | 156 | return score, result_row 157 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | [MASTER] 4 | 5 | # Pickle collected data for later comparisons. 6 | persistent=no 7 | 8 | # Set the cache size for astng objects. 9 | cache-size=500 10 | 11 | [REPORTS] 12 | 13 | # Set the output format. 14 | # output-format=sorted-text 15 | 16 | # Put messages in a separate file for each module / package specified on the 17 | # command line instead of printing them on stdout. Reports (if any) will be 18 | # written in a file name "pylint_global.[txt|html]". 19 | files-output=no 20 | 21 | # Tells whether to display a full report or only the messages. 22 | reports=no 23 | 24 | # Disable the report(s) with the given id(s). 25 | disable-report=R0001,R0002,R0003,R0004,R0101,R0102,R0201,R0202,R0220,R0401,R0402,R0701,R0801,R0901,R0902,R0903,R0904,R0911,R0912,R0913,R0914,R0915,R0921,R0922,R0923 26 | 27 | # Error message template (continued on second line) 28 | msg-template={msg_id}:{line:3} {obj}: {msg} [{symbol}] 29 | 30 | 31 | [MESSAGES CONTROL] 32 | # List of checkers and warnings to enable. 33 | enable=indexing-exception,old-raise-syntax 34 | 35 | # List of checkers and warnings to disable. 36 | disable=design,similarities,no-self-use,attribute-defined-outside-init,locally-disabled,star-args,pointless-except,bad-option-value,global-statement,fixme,suppressed-message,useless-suppression,locally-enabled,file-ignored,multiple-imports,c-extension-no-member,trailing-newlines,unsubscriptable-object,misplaced-comparison-constant,no-member,abstract-method,no-else-return,missing-docstring,wrong-import-order,protected-access,inconsistent-return-statements,invalid-unary-operand-type,import-error,no-name-in-module,arguments-differ 37 | 38 | [BASIC] 39 | 40 | # Required attributes for module, separated by a comma 41 | required-attributes= 42 | 43 | # Regular expression which should only match the name 44 | # of functions or classes which do not require a docstring. 45 | no-docstring-rgx=(__.*__|main) 46 | 47 | # Min length in lines of a function that requires a docstring. 48 | docstring-min-length=10 49 | 50 | # Regular expression which should only match correct module names. The 51 | # leading underscore is sanctioned for private modules by Google's style 52 | # guide. 53 | # 54 | # There are exceptions to the basic rule (_?[a-z][a-z0-9_]*) to cover 55 | # requirements of Python's module system. 56 | module-rgx=^(_?[a-z][a-z0-9_]*)|__init__$ 57 | 58 | # Regular expression which should only match correct module level names 59 | const-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ 60 | 61 | # Regular expression which should only match correct class attribute 62 | class-attribute-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ 63 | 64 | # Regular expression which should only match correct class names 65 | class-rgx=^_?[A-Z][a-zA-Z0-9]*$ 66 | 67 | # Regular expression which should only match correct function names. 68 | # 'camel_case' and 'snake_case' group names are used for consistency of naming 69 | # styles across functions and methods. 70 | function-rgx=^(?:(?PsetUp|tearDown|setUpModule|tearDownModule)|(?P_?[A-Z][a-zA-Z0-9]*)|(?P_?[a-z][a-z0-9_]*))$ 71 | 72 | 73 | # Regular expression which should only match correct method names. 74 | # 'camel_case' and 'snake_case' group names are used for consistency of naming 75 | # styles across functions and methods. 'exempt' indicates a name which is 76 | # consistent with all naming styles. 77 | method-rgx=(?x) 78 | ^(?:(?P_[a-z0-9_]+__|runTest|setUp|tearDown|setUpTestCase 79 | |tearDownTestCase|setupSelf|tearDownClass|setUpClass 80 | |(test|assert)_*[A-Z0-9][a-zA-Z0-9_]*|next) 81 | |(?P_{0,2}[A-Z][a-zA-Z0-9_]*) 82 | |(?P_{0,2}[a-z][a-z0-9_]*))$ 83 | 84 | 85 | # Regular expression which should only match correct instance attribute names 86 | attr-rgx=^_{0,2}[a-z][a-z0-9_]*$ 87 | 88 | # Regular expression which should only match correct argument names 89 | argument-rgx=^[a-z][a-z0-9_]*$ 90 | 91 | # Regular expression which should only match correct variable names 92 | variable-rgx=^[a-z][a-z0-9_]*$ 93 | 94 | # Regular expression which should only match correct list comprehension / 95 | # generator expression variable names 96 | inlinevar-rgx=^[a-z][a-z0-9_]*$ 97 | 98 | # Good variable names which should always be accepted, separated by a comma 99 | good-names=main,_ 100 | 101 | # Bad variable names which should always be refused, separated by a comma 102 | bad-names= 103 | 104 | # List of builtins function names that should not be used, separated by a comma 105 | bad-functions=input,apply,reduce 106 | 107 | # List of decorators that define properties, such as abc.abstractproperty. 108 | property-classes=abc.abstractproperty 109 | 110 | 111 | [TYPECHECK] 112 | 113 | # Tells whether missing members accessed in mixin class should be ignored. A 114 | # mixin class is detected if its name ends with "mixin" (case insensitive). 115 | ignore-mixin-members=yes 116 | 117 | # List of decorators that create context managers from functions, such as 118 | # contextlib.contextmanager. 119 | contextmanager-decorators=contextlib.contextmanager,contextlib2.contextmanager 120 | 121 | 122 | [VARIABLES] 123 | 124 | # Tells whether we should check for unused import in __init__ files. 125 | init-import=no 126 | 127 | # A regular expression matching names used for dummy variables (i.e. not used). 128 | dummy-variables-rgx=^\*{0,2}(_$|unused_|dummy_) 129 | 130 | # List of additional names supposed to be defined in builtins. Remember that 131 | # you should avoid to define new builtins when possible. 132 | additional-builtins= 133 | 134 | 135 | [CLASSES] 136 | 137 | # List of method names used to declare (i.e. assign) instance attributes. 138 | defining-attr-methods=__init__,__new__,setUp 139 | 140 | # "class_" is also a valid for the first argument to a class method. 141 | valid-classmethod-first-arg=cls,class_ 142 | 143 | 144 | [EXCEPTIONS] 145 | 146 | overgeneral-exceptions=StandardError,Exception,BaseException 147 | 148 | 149 | [IMPORTS] 150 | 151 | # Deprecated modules which should not be used, separated by a comma 152 | deprecated-modules=regsub,TERMIOS,Bastion,rexec,sets 153 | 154 | 155 | [FORMAT] 156 | 157 | # Maximum number of characters on a single line. 158 | max-line-length=80 159 | 160 | # Regexp for a line that is allowed to be longer than the limit. 161 | # This "ignore" regex is today composed of several independent parts: 162 | # (1) Long import lines 163 | # (2) URLs in comments or pydocs. Detecting URLs by regex is a hard problem and 164 | # no amount of tweaking will make a perfect regex AFAICT. This one is a good 165 | # compromise. 166 | # (3) Constant string literals at the start of files don't need to be broken 167 | # across lines. Allowing long paths and urls to be on a single 168 | # line. Also requires that the string not be a triplequoted string. 169 | ignore-long-lines=(?x) 170 | (^\s*(import|from)\s 171 | |^\s*(\#\ )??$ 172 | |^[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*("[^"]\S+"|'[^']\S+') 173 | ) 174 | 175 | # Maximum number of lines in a module 176 | max-module-lines=99999 177 | 178 | # String used as indentation unit. We differ from PEP8's normal 4 spaces. 179 | indent-string=' ' 180 | 181 | # Do not warn about multiple statements on a single line for constructs like 182 | # if test: stmt 183 | single-line-if-stmt=y 184 | 185 | # Make sure : in dicts and trailing commas are checked for whitespace. 186 | no-space-check= 187 | 188 | 189 | [LOGGING] 190 | 191 | # Add logging modules. 192 | logging-modules=logging,absl.logging 193 | 194 | 195 | [MISCELLANEOUS] 196 | 197 | # List of note tags to take in consideration, separated by a comma. 198 | notes= 199 | 200 | 201 | # Maximum line length for lambdas 202 | short-func-length=1 203 | 204 | # List of module members that should be marked as deprecated. 205 | # All of the string functions are listed in 4.1.4 Deprecated string functions 206 | # in the Python 2.4 docs. 207 | deprecated-members=string.atof,string.atoi,string.atol,string.capitalize,string.expandtabs,string.find,string.rfind,string.index,string.rindex,string.count,string.lower,string.split,string.rsplit,string.splitfields,string.join,string.joinfields,string.lstrip,string.rstrip,string.strip,string.swapcase,string.translate,string.upper,string.ljust,string.rjust,string.center,string.zfill,string.replace,sys.exitfunc,sys.maxint 208 | 209 | 210 | # List of exceptions that do not need to be mentioned in the Raises section of 211 | # a docstring. 212 | ignore-exceptions=AssertionError,NotImplementedError,StopIteration,TypeError 213 | 214 | 215 | # Number of spaces of indent required when the last token on the preceding line 216 | # is an open (, [, or {. 217 | indent-after-paren=4 218 | --------------------------------------------------------------------------------