Repository: MathisFederico/LearnRL (language: python)

Issue:
Feature/wandb custom graph
Closing #104


Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 0000000..284ad64
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,6 @@
+[report]
+show_missing = True
+omit =
+    learnrl/callbacks/logger.py
+    learnrl/callbacks/tensorboard.py
+    learnrl/callbacks/wandb.py
\ No newline at end of file
diff --git a/.github/workflows/python-coverage.yml b/.github/workflows/python-coverage.yml
new file mode 100644
index 0000000..45eeec8
--- /dev/null
+++ b/.github/workflows/python-coverage.yml
@@ -0,0 +1,30 @@
+# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
+# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
+
+name: Python coverage
+
+on: ["push"]
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v2
+    - name: Set up Python 3.7
+      uses: actions/setup-python@v1
+      with:
+        python-version: 3.7
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install -r requirements.txt
+    - name: Build coverage using pytest-cov
+      run: |
+        pip install -r requirements-dev.txt
+        pip install pytest-cov
+        pytest --cov=learnrl --cov-report=xml tests/unit
+    - name: Upload coverage-report to codacy
+      uses: codacy/codacy-coverage-reporter-action@master
+      with:
+        project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
+        coverage-reports: coverage.xml
\ No newline at end of file
diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/python-pypi.yml
similarity index 93%
rename from .github/workflows/pythonpublish.yml
rename to .github/workflows/python-pypi.yml
index 5ea5607..9d76666 100644
--- a/.github/workflows/pythonpublish.yml
+++ b/.github/workflows/python-pypi.yml
@@ -1,7 +1,7 @@
 # This workflows will upload a Python Package using Twine when a release is created
 # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
 
-name: Upload Python Package
+name: Upload Python on PyPi
 
 on:
   push:
@@ -13,7 +13,7 @@ on:
 jobs:
   deploy:
 
-    runs-on: windows-latest
+    runs-on: ubuntu-latest
 
     steps:
     - uses: actions/checkout@v2
diff --git a/.pylintrc b/.pylintrc
new file mode 100644
index 0000000..f66a82a
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,599 @@
+[MASTER]
+
+# A comma-separated list of package or module names from where C extensions may
+# be loaded. Extensions are loading into the active Python interpreter and may
+# run arbitrary code.
+extension-pkg-whitelist=
+
+# Specify a score threshold to be exceeded before program exits with error.
+fail-under=10.0
+
+# Add files or directories to the blacklist. They should be base names, not
+# paths.
+ignore=CVS
+
+# Add files or directories matching the regex patterns to the blacklist. The
+# regex matches against base names, not paths.
+ignore-patterns=
+
+# Python code to execute, usually for sys.path manipulation such as
+# pygtk.require().
+#init-hook=
+
+# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
+# number of processors available to use.
+jobs=1
+
+# Control the amount of potential inferred values when inferring a single
+# object. This can help the performance when dealing with large functions or
+# complex, nested conditions.
+limit-inference-results=100
+
+# List of plugins (as comma separated values of python module names) to load,
+# usually to register additional checkers.
+load-plugins=pylint_pytest
+
+# Pickle collected data for later comparisons.
+persistent=yes
+
+# When enabled, pylint would attempt to guess common misconfiguration and emit
+# user-friendly hints instead of false-positive error messages.
+suggestion-mode=yes
+
+# Allow loading of arbitrary C extensions. Extensions are imported into the
+# active Python interpreter and may run arbitrary code.
+unsafe-load-any-extension=no
+
+
+[MESSAGES CONTROL]
+
+# Only show warnings with the listed confidence levels. Leave empty to show
+# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED.
+confidence=
+
+# Disable the message, report, category or checker with the given id(s). You
+# can either give multiple identifiers separated by comma (,) or put this
+# option multiple times (only on the command line, not in the configuration
+# file where it should appear only once). You can also use "--disable=all" to
+# disable everything first and then reenable specific checks. For example, if
+# you want to run only the similarities checker, you can use "--disable=all
+# --enable=similarities". If you want to run only the classes checker, but have
+# no Warning level messages displayed, use "--disable=all --enable=classes
+# --disable=W".
+disable=print-statement,
+        no-name-in-module,
+        no-member,
+        no-self-use,
+        too-many-locals,
+        duplicate-code,
+        unused-wildcard-import,
+        wildcard-import,
+        redefined-outer-name,
+        too-many-arguments,
+        too-few-public-methods,
+        parameter-unpacking,
+        unpacking-in-except,
+        old-raise-syntax,
+        backtick,
+        long-suffix,
+        old-ne-operator,
+        old-octal-literal,
+        import-star-module-level,
+        non-ascii-bytes-literal,
+        raw-checker-failed,
+        bad-inline-option,
+        locally-disabled,
+        file-ignored,
+        suppressed-message,
+        useless-suppression,
+        deprecated-pragma,
+        use-symbolic-message-instead,
+        apply-builtin,
+        basestring-builtin,
+        buffer-builtin,
+        cmp-builtin,
+        coerce-builtin,
+        execfile-builtin,
+        file-builtin,
+        long-builtin,
+        raw_input-builtin,
+        reduce-builtin,
+        standarderror-builtin,
+        unicode-builtin,
+        xrange-builtin,
+        coerce-method,
+        delslice-method,
+        getslice-method,
+        setslice-method,
+        no-absolute-import,
+        old-division,
+        dict-iter-method,
+        dict-view-method,
+        next-method-called,
+        metaclass-assignment,
+        indexing-exception,
+        raising-string,
+        reload-builtin,
+        oct-method,
+        hex-method,
+        nonzero-method,
+        cmp-method,
+        input-builtin,
+        round-builtin,
+        intern-builtin,
+        unichr-builtin,
+        map-builtin-not-iterating,
+        zip-builtin-not-iterating,
+        range-builtin-not-iterating,
+        filter-builtin-not-iterating,
+        using-cmp-argument,
+        eq-without-hash,
+        div-method,
+        idiv-method,
+        rdiv-method,
+        exception-message-attribute,
+        invalid-str-codec,
+        sys-max-int,
+        bad-python3-import,
+        deprecated-string-function,
+        deprecated-str-translate-call,
+        deprecated-itertools-function,
+        deprecated-types-field,
+        next-method-defined,
+        dict-items-not-iterating,
+        dict-keys-not-iterating,
+        dict-values-not-iterating,
+        deprecated-operator-function,
+        deprecated-urllib-function,
+        xreadlines-attribute,
+        deprecated-sys-function,
+        exception-escape,
+        comprehension-escape,
+        cyclic-import
+
+# Enable the message, report, category or checker with the given id(s). You can
+# either give multiple identifier separated by comma (,) or put this option
+# multiple time (only on the command line, not in the configuration file where
+# it should appear only once). See also the "--disable" option for examples.
+enable=c-extension-no-member
+
+
+[REPORTS]
+
+# Python expression which should return a score less than or equal to 10. You
+# have access to the variables 'error', 'warning', 'refactor', and 'convention'
+# which contain the number of messages in each category, as well as 'statement'
+# which is the total number of statements analyzed. This score is used by the
+# global evaluation report (RP0004).
+evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
+
+# Template used to display messages. This is a python new-style format string
+# used to format the message information. See doc for all details.
+#msg-template=
+
+# Set the output format. Available formats are text, parseable, colorized, json
+# and msvs (visual studio). You can also give a reporter class, e.g.
+# mypackage.mymodule.MyReporterClass.
+output-format=text
+
+# Tells whether to display a full report or only the messages.
+reports=no
+
+# Activate the evaluation score.
+score=yes
+
+
+[REFACTORING]
+
+# Maximum number of nested blocks for function / method body
+max-nested-blocks=5
+
+# Complete name of functions that never returns. When checking for
+# inconsistent-return-statements if a never returning function is called then
+# it will be considered as an explicit return statement and no message will be
+# printed.
+never-returning-functions=sys.exit
+
+
+[BASIC]
+
+# Naming style matching correct argument names.
+argument-naming-style=snake_case
+
+# Regular expression matching correct argument names. Overrides argument-
+# naming-style.
+#argument-rgx=
+
+# Naming style matching correct attribute names.
+attr-naming-style=snake_case
+
+# Regular expression matching correct attribute names. Overrides attr-naming-
+# style.
+#attr-rgx=
+
+# Bad variable names which should always be refused, separated by a comma.
+bad-names=foo,
+          bar,
+          baz,
+          toto,
+          tutu,
+          tata
+
+# Bad variable names regexes, separated by a comma. If names match any regex,
+# they will always be refused
+bad-names-rgxs=
+
+# Naming style matching correct class attribute names.
+class-attribute-naming-style=any
+
+# Regular expression matching correct class attribute names. Overrides class-
+# attribute-naming-style.
+#class-attribute-rgx=
+
+# Naming style matching correct class names.
+class-naming-style=PascalCase
+
+# Regular expression matching correct class names. Overrides class-naming-
+# style.
+#class-rgx=
+
+# Naming style matching correct constant names.
+const-naming-style=UPPER_CASE
+
+# Regular expression matching correct constant names. Overrides const-naming-
+# style.
+#const-rgx=
+
+# Minimum line length for functions/classes that require docstrings, shorter
+# ones are exempt.
+docstring-min-length=-1
+
+# Naming style matching correct function names.
+function-naming-style=snake_case
+
+# Regular expression matching correct function names. Overrides function-
+# naming-style.
+#function-rgx=
+
+# Good variable names which should always be accepted, separated by a comma.
+good-names=i,
+           j,
+           k,
+           ex,
+           Run,
+           _
+
+# Good variable names regexes, separated by a comma. If names match any regex,
+# they will always be accepted
+good-names-rgxs=
+
+# Include a hint for the correct naming format with invalid-name.
+include-naming-hint=no
+
+# Naming style matching correct inline iteration names.
+inlinevar-naming-style=any
+
+# Regular expression matching correct inline iteration names. Overrides
+# inlinevar-naming-style.
+#inlinevar-rgx=
+
+# Naming style matching correct method names.
+method-naming-style=snake_case
+
+# Regular expression matching correct method names. Overrides method-naming-
+# style.
+#method-rgx=
+
+# Naming style matching correct module names.
+module-naming-style=snake_case
+
+# Regular expression matching correct module names. Overrides module-naming-
+# style.
+#module-rgx=
+
+# Colon-delimited sets of names that determine each other's naming style when
+# the name regexes allow several styles.
+name-group=
+
+# Regular expression which should only match function or class names that do
+# not require a docstring.
+no-docstring-rgx=^_
+
+# List of decorators that produce properties, such as abc.abstractproperty. Add
+# to this list to register other decorators that produce valid properties.
+# These decorators are taken in consideration only for invalid-name.
+property-classes=abc.abstractproperty
+
+# Naming style matching correct variable names.
+variable-naming-style=snake_case
+
+# Regular expression matching correct variable names. Overrides variable-
+# naming-style.
+#variable-rgx=
+
+
+[FORMAT]
+
+# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
+expected-line-ending-format=
+
+# Regexp for a line that is allowed to be longer than the limit.
+ignore-long-lines=^\s*(# )?<?https?://\S+>?$
+
+# Number of spaces of indent required inside a hanging or continued line.
+indent-after-paren=4
+
+# String used as indentation unit. This is usually "    " (4 spaces) or "\t" (1
+# tab).
+indent-string='    '
+
+# Maximum number of characters on a single line.
+max-line-length=100
+
+# Maximum number of lines in a module.
+max-module-lines=1000
+
+# Allow the body of a class to be on the same line as the declaration if body
+# contains single statement.
+single-line-class-stmt=no
+
+# Allow the body of an if to be on the same line as the test if there is no
+# else.
+single-line-if-stmt=no
+
+
+[LOGGING]
+
+# The type of string formatting that logging methods do. `old` means using %
+# formatting, `new` is for `{}` formatting.
+logging-format-style=old
+
+# Logging modules to check that the string format arguments are in logging
+# function parameter format.
+logging-modules=logging
+
+
+[MISCELLANEOUS]
+
+# List of note tags to take in consideration, separated by a comma.
+notes=FIXME,
+      XXX,
+      TODO
+
+# Regular expression of note tags to take in consideration.
+#notes-rgx=
+
+
+[SIMILARITIES]
+
+# Ignore comments when computing similarities.
+ignore-comments=yes
+
+# Ignore docstrings when computing similarities.
+ignore-docstrings=yes
+
+# Ignore imports when computing similarities.
+ignore-imports=no
+
+# Minimum lines number of a similarity.
+min-similarity-lines=4
+
+
+[SPELLING]
+
+# Limits count of emitted suggestions for spelling mistakes.
+max-spelling-suggestions=4
+
+# Spelling dictionary name. Available dictionaries: none. To make it work,
+# install the python-enchant package.
+spelling-dict=
+
+# List of comma separated words that should not be checked.
+spelling-ignore-words=
+
+# A path to a file that contains the private dictionary; one word per line.
+spelling-private-dict-file=
+
+# Tells whether to store unknown words to the private dictionary (see the
+# --spelling-private-dict-file option) instead of raising a message.
+spelling-store-unknown-words=no
+
+
+[STRING]
+
+# This flag controls whether inconsistent-quotes generates a warning when the
+# character used as a quote delimiter is used inconsistently within a module.
+check-quote-consistency=no
+
+# This flag controls whether the implicit-str-concat should generate a warning
+# on implicit string concatenation in sequences defined over several lines.
+check-str-concat-over-line-jumps=no
+
+
+[TYPECHECK]
+
+# List of decorators that produce context managers, such as
+# contextlib.contextmanager. Add to this list to register other decorators that
+# produce valid context managers.
+contextmanager-decorators=contextlib.contextmanager
+
+# List of members which are set dynamically and missed by pylint inference
+# system, and so shouldn't trigger E1101 when accessed. Python regular
+# expressions are accepted.
+generated-members=
+
+# Tells whether missing members accessed in mixin class should be ignored. A
+# mixin class is detected if its name ends with "mixin" (case insensitive).
+ignore-mixin-members=yes
+
+# Tells whether to warn about missing members when the owner of the attribute
+# is inferred to be None.
+ignore-none=yes
+
+# This flag controls whether pylint should warn about no-member and similar
+# checks whenever an opaque object is returned when inferring. The inference
+# can return multiple potential results while evaluating a Python object, but
+# some branches might not be evaluated, which results in partial inference. In
+# that case, it might be useful to still emit no-member and other checks for
+# the rest of the inferred objects.
+ignore-on-opaque-inference=yes
+
+# List of class names for which member attributes should not be checked (useful
+# for classes with dynamically set attributes). This supports the use of
+# qualified names.
+ignored-classes=optparse.Values,thread._local,_thread._local
+
+# List of module names for which member attributes should not be checked
+# (useful for modules/projects where namespaces are manipulated during runtime
+# and thus existing member attributes cannot be deduced by static analysis). It
+# supports qualified module names, as well as Unix pattern matching.
+ignored-modules=
+
+# Show a hint with possible names when a member name was not found. The aspect
+# of finding the hint is based on edit distance.
+missing-member-hint=yes
+
+# The minimum edit distance a name should have in order to be considered a
+# similar match for a missing member name.
+missing-member-hint-distance=1
+
+# The total number of similar names that should be taken in consideration when
+# showing a hint for a missing member.
+missing-member-max-choices=1
+
+# List of decorators that change the signature of a decorated function.
+signature-mutators=
+
+
+[VARIABLES]
+
+# List of additional names supposed to be defined in builtins. Remember that
+# you should avoid defining new builtins when possible.
+additional-builtins=
+
+# Tells whether unused global variables should be treated as a violation.
+allow-global-unused-variables=yes
+
+# List of strings which can identify a callback function by name. A callback
+# name must start or end with one of those strings.
+callbacks=cb_,
+          _cb
+
+# A regular expression matching the name of dummy variables (i.e. expected to
+# not be used).
+dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
+
+# Argument names that match this expression will be ignored. Default to name
+# with leading underscore.
+ignored-argument-names=_.*|^ignored_|^unused_
+
+# Tells whether we should check for unused import in __init__ files.
+init-import=no
+
+# List of qualified module names which can have objects that can redefine
+# builtins.
+redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
+
+
+[CLASSES]
+
+# List of method names used to declare (i.e. assign) instance attributes.
+defining-attr-methods=__init__,
+                      __new__,
+                      setUp,
+                      __post_init__
+
+# List of member names, which should be excluded from the protected access
+# warning.
+exclude-protected=_asdict,
+                  _fields,
+                  _replace,
+                  _source,
+                  _make
+
+# List of valid names for the first argument in a class method.
+valid-classmethod-first-arg=cls
+
+# List of valid names for the first argument in a metaclass class method.
+valid-metaclass-classmethod-first-arg=cls
+
+
+[DESIGN]
+
+# Maximum number of arguments for function / method.
+max-args=5
+
+# Maximum number of attributes for a class (see R0902).
+max-attributes=7
+
+# Maximum number of boolean expressions in an if statement (see R0916).
+max-bool-expr=5
+
+# Maximum number of branch for function / method body.
+max-branches=12
+
+# Maximum number of locals for function / method body.
+max-locals=15
+
+# Maximum number of parents for a class (see R0901).
+max-parents=7
+
+# Maximum number of public methods for a class (see R0904).
+max-public-methods=20
+
+# Maximum number of return / yield for function / method body.
+max-returns=6
+
+# Maximum number of statements in function / method body.
+max-statements=50
+
+# Minimum number of public methods for a class (see R0903).
+min-public-methods=2
+
+
+[IMPORTS]
+
+# List of modules that can be imported at any level, not just the top level
+# one.
+allow-any-import-level=
+
+# Allow wildcard imports from modules that define __all__.
+allow-wildcard-with-all=no
+
+# Analyse import fallback blocks. This can be used to support both Python 2 and
+# 3 compatible code, which means that the block might have code that exists
+# only in one or another interpreter, leading to false positives when analysed.
+analyse-fallback-blocks=no
+
+# Deprecated modules which should not be used, separated by a comma.
+deprecated-modules=optparse,tkinter.tix
+
+# Create a graph of external dependencies in the given file (report RP0402 must
+# not be disabled).
+ext-import-graph=
+
+# Create a graph of every (i.e. internal and external) dependencies in the
+# given file (report RP0402 must not be disabled).
+import-graph=
+
+# Create a graph of internal dependencies in the given file (report RP0402 must
+# not be disabled).
+int-import-graph=
+
+# Force import order to recognize a module as part of the standard
+# compatibility libraries.
+known-standard-library=
+
+# Force import order to recognize a module as part of a third party library.
+known-third-party=enchant
+
+# Couples of modules and preferred modules, separated by a comma.
+preferred-modules=
+
+
+[EXCEPTIONS]
+
+# Exceptions that will emit a warning when being caught. Defaults to
+# "BaseException, Exception".
+overgeneral-exceptions=BaseException,
+                       Exception
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
deleted file mode 100644
index 7812620..0000000
--- a/CONTRIBUTING.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# We are using git flow !
-Download:
-https://github.com/petervanderdoes/gitflow-avh/wiki/Installation
-
-See this cheatsheet for fast info:
-https://danielkummer.github.io/git-flow-cheatsheet/index.fr_FR.html
-
-## 1 : Pick or make and issue corresponding to the modification you want to do
-
-## 2 : Start a new feature with the same name as the issue
-git flow feature start MYFEATURE 
-
-## 3 : Work on this branch as you wish
-If you need another feature : git flow feature pull origin OTHERFEATURE 
-
-## 4 : When your feature is done, DO A MERGE REQUEST TO DEV
-
-## 5 : When it has been merged you can finish it locally
-git flow feature finish MYFEATURE
-
-## 5 : After testing and buf-fixing, maybe your fix/add will end up on master !
diff --git a/MANIFEST.in b/MANIFEST.in
deleted file mode 100644
index 1f5dcbb..0000000
--- a/MANIFEST.in
+++ /dev/null
@@ -1,1 +0,0 @@
-recursive-include learnrl *.png
\ No newline at end of file
diff --git a/README.rst b/README.rst
index 0e3923f..7bd7b8e 100644
--- a/README.rst
+++ b/README.rst
@@ -1,75 +1,36 @@
-Welcome to LearnRL's documentation!
-===================================
+Welcome to LearnRL's community !
+================================
 
-We would love to help you make projects with LearnRL, so join us `on Discord <https://discord.gg/z9dd4s5>`_ !
-
-About LearnRL
--------------
+.. image:: https://img.shields.io/pypi/l/learnrl
+   :alt: PyPI - License
+   :target: https://www.gnu.org/licenses/
 
-LearnRL is a library to use and learn reinforcement learning.
 
-It is very easy to use:
+.. image:: https://app.codacy.com/project/badge/Grade/b4c3818135484e8b9acae67b01526957
+   :alt: Codacy - Quality
+   :target: https://www.codacy.com/gh/MathisFederico/LearnRL/dashboard?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=MathisFederico/LearnRL&amp;utm_campaign=Badge_Grade
 
-.. code-block:: python
 
-   import learnrl as rl
-   from learnrl.agents import StandardAgent
+.. image:: https://app.codacy.com/project/badge/Coverage/b4c3818135484e8b9acae67b01526957
+   :alt: Codacy - Coverage
+   :target: https://www.codacy.com/gh/MathisFederico/LearnRL/dashboard?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=MathisFederico/LearnRL&amp;utm_campaign=Badge_Coverage
 
-   import gym
-
-   env = gym.make('FrozenLake-v0', is_slippery=True)
-   agent = StandardAgent(env.observation_space, env.action_space,
-                        exploration=1, exploration_decay=1e-4)
-
-   pg = rl.Playground(env, agent)
-   pg.fit(2000, verbose=1)
-
-And very modular and customizable !
-For example we can overide the evaluation method (how future rewards are expected)
-and/or the control method (how a decision is made based on action value):
-
-.. code-block:: python
-
-   import learnrl as rl
-   from learnrl.agents import StandardAgent
-
-   import gym
-
-   env = gym.make('FrozenLake-v0', is_slippery=True)
-
-   class MyEvaluation(rl.Evaluation):
-
-      """ MyEvaluation uses ... to approximate the expected return at each step. """
-
-      def __init__(self, **kwargs):
-         super().__init__(name="myevaluation", **kwargs)
-
-      def eval(self, reward, done, next_observation, action_values, action_visits, control):
-         ...
-         return expected_futur_reward
-      
-   class MyControl(rl.Control):
-
-      """ MyControl will make the policy given action values Q (and action visits N) """
-
-      def __init__(self, exploration=1, **kwargs):
-         super().__init__(exploration=exploration, name="mycontrol", **kwargs)
-         self.need_action_visit = True # This is optional, here to ensure that N is given
-
-      def policy(self, Q, N=None):
-         ...
-         return p
 
-   evaluation = MyEvaluation(learning_rate=1e-2)
-   control = MyControl(exploration=1, exploration_decay=1e-4)
+LearnRL is a library to use and learn reinforcement learning.
+It's also a community off supportive enthousiasts loving to share and build RL-based AI projects !
+We would love to help you make projects with LearnRL, so join us `on Discord <https://discord.gg/z9dd4s5>`_ !
 
-   agent = StandardAgent(env.observation_space, env.action_space,
-                        evaluation=evaluation, control=control)
+About LearnRL
+-------------
 
-   pg = rl.Playground(env, agent)
-   pg.fit(2000, verbose=1)
+LearnRL is a tool to monitor and log reinforcement learning experiments.
+You build/find any compatible agent (only need an act method), you build/find a gym environment, and learnrl will make them interact together !
+LearnRL also contains both tensorboard and weights&biases integrations for a beautiful and sharable experiment tracking !  
+Also, LearnRL is cross platform compatible ! That's why no agents are built-in learnrl itself, but you can check:
+   - `LearnRL for Tensorflow <https://github.com/MathisFederico/LearnRL-Tensorflow>`_
+   - `LearnRL for Pytorch <https://github.com/MathisFederico/LearnRL-Pytorch>`_
 
-You can of course build your own Agent and/or Environment from scratch !
+You can build and run your own Agent in a clear and sharable manner !
 
 .. code-block:: python
 
@@ -90,22 +51,50 @@ You can of course build your own Agent and/or Environment from scratch !
 
       def remember(self, observation, action, reward, done, next_observation=None, info={}, **param):
          """ How the Agent will remember experiences """
-         pass
+         ...
 
-   env = gym.make('FrozenLake-v0', is_slippery=True)
+   env = gym.make('FrozenLake-v0', is_slippery=True) # This could be any gym Environment !
    agent = MyAgent(env.observation_space, env.action_space)
 
    pg = rl.Playground(env, agent)
    pg.fit(2000, verbose=1)
 
-Note that 'learn' and 'remember' are optional, so this can also be used for baselines.
+Note that 'learn' and 'remember' are optional, so this framework can also be used for baselines !
+
+You can logs any custom metrics that your Agent/Env gives you and even chose how to aggregate them through different timescales.
+See the `metric codes <https://learnrl.readthedocs.io/en/latest/callbacks.html#metric-codes>`_ for more details.
+
+.. code-block:: python
+
+   metrics=[
+        ('reward~env-rwd', {'steps': 'sum', 'episode': 'sum'}),
+        ('handled_reward~reward', {'steps': 'sum', 'episode': 'sum'}),
+        'value_loss~vloss',
+        'actor_loss~aloss',
+        'exploration~exp'
+    ]
+
+   pg.fit(2000, verbose=1, metrics=metrics)
+
+The Playground will allow you to have clean logs adapted to your will with the verbose parameter:
+  - Verbose 1 : episodes cycles - If your environment makes a lot of quick episodes.
+   .. image:: docs\_static\images\logs-verbose-1.png
+
+  - Verbose 2 : episode - To log each individual episode.
+   .. image:: docs\_static\images\logs-verbose-2.png
+
+  - Verbose 3 : steps cycles - If your environment makes a lot of quick steps but has long episodes.
+   .. image:: docs\_static\images\logs-verbose-3.png
 
-Features
---------
+  - Verbose 4 : step - To log each individual step.
+   .. image:: docs\_static\images\logs-verbose-4.png
+
+  - Verbose 5 : detailled step - To debug each individual step (with observations, actions, ...).
+   .. image:: docs\_static\images\logs-verbose-5.png
+
+
+The Playground also allows you to add Callbacks with ease, for example the WandbCallback to have a nice experiment tracking dashboard !
 
-- Build highly configurable classic reinforcement learning agents in few lines of code.
-- Train your Agents on any Gym or custom environment.
-- Use this API to create your own agents and environments (even multiplayer!) with great compatibility.
 
 Installation
 ------------
@@ -114,23 +103,46 @@ Install LearnRL by running::
 
    pip install learnrl
 
+
+Get started
+-----------
+
+Create:
+   - TODO: Numpy tutorials
+   - TODO: Tensorflow tutorials
+   - TODO: Pytorch tutorials
+
+
+Visualize:
+   - TODO: Tensorboard visualisation tutorial
+   - TODO: Wandb visualisation tutorial
+   - TODO: Wandb sweeps tutorial
+
+
 Documentation
 -------------
 
+.. image:: docs\_static\images\docs.png
+   :target: https://learnrl.readthedocs.io/en/latest/
+
+
 | See the `latest complete documentation <https://learnrl.readthedocs.io/en/latest/>`_ for more details.
 | See the `development documentation <https://learnrl.readthedocs.io/en/dev/>`_ to see what's coming !
 
+
 Contribute
 ----------
 
 - `Issue Tracker <https://github.com/MathisFederico/LearnRL/issues>`_.
 - `Projects <https://github.com/MathisFederico/LearnRL/projects>`_.
 
+
 Support
 -------
 
 If you are having issues, please contact us `on Discord <https://discord.gg/z9dd4s5>`_.
 
+
 License
 -------
 
diff --git a/VERSION b/VERSION
index 0c62199..afaf360 100644
--- a/VERSION
+++ b/VERSION
@@ -1,1 +1,1 @@
-0.2.1
+1.0.0
\ No newline at end of file
diff --git a/bandit.yml b/bandit.yml
new file mode 100644
index 0000000..b7aee05
--- /dev/null
+++ b/bandit.yml
@@ -0,0 +1,4 @@
+# Do not check paths including `/tests/`:
+# they use `assert`, leading to B101 false positives.
+exclude_dirs:
+    - '/tests/'
diff --git a/docs/_static/images/docs.png b/docs/_static/images/docs.png
new file mode 100644
index 0000000..3e565f4
Binary files /dev/null and b/docs/_static/images/docs.png differ
diff --git a/docs/_static/images/logs-verbose-1.png b/docs/_static/images/logs-verbose-1.png
new file mode 100644
index 0000000..be14c9b
Binary files /dev/null and b/docs/_static/images/logs-verbose-1.png differ
diff --git a/docs/_static/images/logs-verbose-2.png b/docs/_static/images/logs-verbose-2.png
new file mode 100644
index 0000000..6ca4887
Binary files /dev/null and b/docs/_static/images/logs-verbose-2.png differ
diff --git a/docs/_static/images/logs-verbose-3.png b/docs/_static/images/logs-verbose-3.png
new file mode 100644
index 0000000..ff6c920
Binary files /dev/null and b/docs/_static/images/logs-verbose-3.png differ
diff --git a/docs/_static/images/logs-verbose-4.png b/docs/_static/images/logs-verbose-4.png
new file mode 100644
index 0000000..a9bf58f
Binary files /dev/null and b/docs/_static/images/logs-verbose-4.png differ
diff --git a/docs/_static/images/logs-verbose-5.png b/docs/_static/images/logs-verbose-5.png
new file mode 100644
index 0000000..e334bc8
Binary files /dev/null and b/docs/_static/images/logs-verbose-5.png differ
diff --git a/docs/agent_parts.rst b/docs/agent_parts.rst
deleted file mode 100644
index b1e8672..0000000
--- a/docs/agent_parts.rst
+++ /dev/null
@@ -1,14 +0,0 @@
-.. _Agent_parts:
-
-Agent_parts
-===========
-
-Agent parts are building blocks used to create agents of any sort !
-
-.. toctree::
-   :maxdepth: 2
-
-   agent_parts/control
-   agent_parts/evaluation
-   agent_parts/estimator
-   
diff --git a/docs/agent_parts/control.rst b/docs/agent_parts/control.rst
deleted file mode 100644
index 4da0e34..0000000
--- a/docs/agent_parts/control.rst
+++ /dev/null
@@ -1,26 +0,0 @@
-.. _Control:
-
-Control
-=======
-
-Control API
------------
-
-.. autoclass:: learnrl.control.Control
-   :members:
-   :undoc-members:
-
-Build-in controls
------------------
-
-.. autoclass:: learnrl.control.Greedy
-   :members:
-   :undoc-members:
-
-.. autoclass:: learnrl.control.Ucb
-   :members:
-   :undoc-members:
-
-.. autoclass:: learnrl.control.Puct
-   :members:
-   :undoc-members:
diff --git a/docs/agent_parts/estimator.rst b/docs/agent_parts/estimator.rst
deleted file mode 100644
index c87973a..0000000
--- a/docs/agent_parts/estimator.rst
+++ /dev/null
@@ -1,24 +0,0 @@
-.. _Estimator:
-
-Estimator
-=========
-
-Estimator API
--------------
-
-.. autoclass:: learnrl.estimators.Estimator
-   :members:
-   :undoc-members:
-
-Build-in estimators
--------------------
-
-.. autoclass:: learnrl.estimators.TableEstimator
-   :members:
-   :undoc-members:
-
-.. autoclass:: learnrl.estimators.KerasEstimator
-   :members:
-   :undoc-members:
-
-.. |gym.Space| replace:: `space <http://gym.openai.com/docs/#spaces>`__
diff --git a/docs/agent_parts/evaluation.rst b/docs/agent_parts/evaluation.rst
deleted file mode 100644
index 0ab6045..0000000
--- a/docs/agent_parts/evaluation.rst
+++ /dev/null
@@ -1,26 +0,0 @@
-.. _Evaluation:
-
-Evaluation
-==========
-
-Evaluation API
---------------
-
-.. autoclass:: learnrl.evaluation.Evaluation
-   :members:
-   :undoc-members:
-
-Build-in evaluations
---------------------
-
-.. autoclass:: learnrl.evaluation.MonteCarlo
-   :members:
-   :undoc-members:
-
-.. autoclass:: learnrl.evaluation.TemporalDifference
-   :members:
-   :undoc-members:
-
-.. autoclass:: learnrl.evaluation.QLearning
-   :members:
-   :undoc-members:
diff --git a/docs/agents.rst b/docs/agents.rst
deleted file mode 100644
index c6daebe..0000000
--- a/docs/agents.rst
+++ /dev/null
@@ -1,11 +0,0 @@
-Agents
-======
-
-| LearnRL gives an API for reinforcement learning agents.
-| The core Agent object is :ref:`Agent`
-
-.. toctree::
-   :maxdepth: 2
-
-   agent_parts
-   agents/standard
diff --git a/docs/agents/standard.rst b/docs/agents/standard.rst
deleted file mode 100644
index 7af8cbd..0000000
--- a/docs/agents/standard.rst
+++ /dev/null
@@ -1,91 +0,0 @@
-StandardAgent
-=============
-
-StandardAgent are composed of three :ref:`Agent_parts`:
-- :ref:`Control`
-- :ref:`Evaluation`
-- :ref:`Estimator`
-
-They are all linked by the :class:`~learnrl.agents.StandardAgent`, as showned by this:
-
-.. raw:: html
-   :file: ../_static/images/StandardAgent.svg
-
-
-TableAgent
-----------
-
-| Table agents are the simplest form of RL Agents.
-| With experience, we build an action_value array (Q in literature).
-| Q(s,a) being the expected futur rewards given that the agent did the action a in the state s.
-| For that, they are composed of two main objects : :ref:`Control` and :ref:`Evaluation`.
-
-| A :class:`~learnrl.control.Control` object uses the action_value to determine the policy or behavior of the agent.
-| An :class:`~learnrl.evaluation.Evaluation` object predicts the expected rewards from an experience given a behavior.
-
-| By default, when building a :class:`~learnrl.agents.StandardAgent`, it will not use DeepLearning (using a :class:`~learnrl.estimators.TableEstimator`) 
-| with :class:`~learnrl.control.Greedy` control and :class:`~learnrl.evaluation.QLearning` evaluation.
-
-Here is an example :
-
-.. code-block:: python
-
-   from learnrl.agents import StandardAgent
-
-   agent_table = StandardAgent(observation_space=observation_space,
-                               action_space=action_space,
-                               exploration=0.1,
-                               exploration_decay=1e-2,
-                               learning_rate=0.1)
-
-
-DeepRLAgent
------------
-
-| If we replace the :class:`~learnrl.estimators.TableEstimator` by any other estimator, we can build approximations of Q(s,a).
-| This is way more suitable in general because the :class:`~learnrl.estimators.TableEstimator` needs too much ram for real applications.
-| For example, replacing the action_value by a :class:`~learnrl.estimators.KerasEstimator` and completing the methods build and preprocess like so :
-
-.. code-block:: python
-
-   from learnrl.agent_parts.estimator import KerasEstimator
-   from learnrl.agents import StandardAgent
-
-   class MyEstimator(KerasEstimator):
-
-    def build(self):
-        
-        self.model = Sequential()
-        self.model.add(Flatten(input_shape=self.observation_shape))
-        self.model.add(Dense(16, activation='relu'))
-        self.model.add(Dense(self.action_size))
-
-        self.model.compile(Adam(learning_rate=self.learning_rate), loss='mse')
-
-    def preprocess(self, observations, actions=None):
-        X = copy(observations)
-        X[X==2] = -1
-        return X
-   
-   custom_action_value = MyEstimator(observation_space=observation_space,
-                                     action_space=action_space,
-                                     epochs_per_step=10,
-                                     batch_size=32,
-                                     learning_rate=1e-2,
-                                     freezed_steps=100,
-                                     verbose=0)
-   
-   agent_deep = StandardAgent(observation_space=observation_space,
-                              action_space=action_space,
-                              action_values=custom_action_value)
-
-
-| You built a neural network to make an approximation of Q(s,a) !
-
-.. autoclass:: learnrl.agents.StandardAgent
-   :members:
-   :undoc-members:
-
-.. |gym.Env| replace:: `environment <http://gym.openai.com/docs/#environments>`__
-.. |gym.Space| replace:: `space <http://gym.openai.com/docs/#spaces>`__
-.. |hash| replace:: `perfect hash functions <https://en.wikipedia.org/wiki/Perfect_hash_function>`__
diff --git a/docs/callbacks.rst b/docs/callbacks.rst
index 45e7f21..e1706a1 100644
--- a/docs/callbacks.rst
+++ b/docs/callbacks.rst
@@ -1,8 +1,7 @@
-.. _Callbacks:
-
 Callbacks
 =========
 
+.. _Callbacks:
 
 Callback API
 ------------
diff --git a/docs/conf.py b/docs/conf.py
index 9efc783..c3602a9 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -24,7 +24,7 @@
 def get_version():
     version_file = open('../VERSION')
     return version_file.read().strip()
- 
+
 version = get_version()
 release = version
 
@@ -38,7 +38,7 @@ def get_version():
     'sphinx.ext.inheritance_diagram',
     'sphinx.ext.intersphinx',
     'sphinx.ext.napoleon',
-    'autoapi.sphinx'
+    'sphinx_autodoc_typehints',
 ]
 master_doc = 'index'
 autoapi_dirs = ['../learnrl']
@@ -48,6 +48,8 @@ def get_version():
     'undoc-members': True,
 }
 add_module_names = False
+autoclass_content = 'both'
+napoleon_use_param = True
 # pygments_style = 'monokai'
 
 intersphinx_mapping = {
@@ -99,4 +101,3 @@ def get_version():
 html_static_path = ['_static']
 def setup(app):
     app.add_css_file('styles/custom.css')
- 
diff --git a/docs/core.rst b/docs/core.rst
index 8499cba..efde428 100644
--- a/docs/core.rst
+++ b/docs/core.rst
@@ -2,7 +2,7 @@ LearnRL's Core
 ==============
 
 LearnRL is based on those core objects: :ref:`Playground`,
-:ref:`Agent`, :ref:`TurnEnv`, :ref:`Memory`.
+:ref:`Agent`, :ref:`TurnEnv`.
 
 They are all linked by the :ref:`Playground`, as showned by this:
 
@@ -30,21 +30,29 @@ Agent
 
 .. _TurnEnv:
 
-MultiEnv
---------
+TurnEnv
+-------
 
 .. autoclass:: learnrl.envs.TurnEnv
    :members:
 
+Handlers
+--------
+
+RewardHandler
+~~~~~~~~~~~~~
+
+.. autoclass:: learnrl.playground.RewardHandler
+   :members:
 
-.. _Memory:
 
-Memory
-------
+DoneHandler
+~~~~~~~~~~~
 
-.. autoclass:: learnrl.memory.Memory
+.. autoclass:: learnrl.playground.DoneHandler
    :members:
 
+
 .. |ndarray| replace:: :class:`numpy.ndarray`
 .. |gym.Env| replace:: `environment <http://gym.openai.com/docs/#environments>`__
 .. |gym.Space| replace:: `space <http://gym.openai.com/docs/#spaces>`__
diff --git a/docs/environments.rst b/docs/environments.rst
deleted file mode 100644
index f41c8d2..0000000
--- a/docs/environments.rst
+++ /dev/null
@@ -1,11 +0,0 @@
-Environments
-============
-
-In LearnRL, all gym environments are supported ! (And you can import them from `learnrl.environments`)
-Moreover, you can even build environments for multiple agent using a :ref:`TurnEnv`.
-
-.. toctree::
-   :maxdepth: 2
-
-   environments/crosses_and_noughts
-   environments/random_nim
diff --git a/docs/environments/crosses_and_noughts.rst b/docs/environments/crosses_and_noughts.rst
deleted file mode 100644
index 06ba66b..0000000
--- a/docs/environments/crosses_and_noughts.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-CrossesAndNoughtsEnv
-====================
-
-.. automodule:: learnrl.environments.multi_agents.crosses_and_noughts
-   :members:
-
-.. |gym.Env| replace:: `environment <http://gym.openai.com/docs/#environments>`__
diff --git a/docs/environments/random_nim.rst b/docs/environments/random_nim.rst
deleted file mode 100644
index fef7fef..0000000
--- a/docs/environments/random_nim.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-RdNimEnv
-========
-
-.. automodule:: learnrl.environments.single_agents.random_nim
-   :members:
diff --git a/docs/index.rst b/docs/index.rst
index 79a521a..16569ad 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,75 +1,34 @@
-Welcome to LearnRL's documentation!
-===================================
+Welcome to LearnRL's community !
+================================
 
-We would love to help you make projects with LearnRL, so join us `on Discord <https://discord.gg/z9dd4s5>`_ !
-
-About LearnRL
--------------
-
-LearnRL is a library to use and learn reinforcement learning.
-
-It is very easy to use:
+.. image:: https://img.shields.io/pypi/l/learnrl
+   :alt: PyPI - License
+   :target: https://www.gnu.org/licenses/
 
-.. code-block:: python
-
-   import learnrl as rl
-   from learnrl.agents import StandardAgent
+.. image:: https://app.codacy.com/project/badge/Grade/b4c3818135484e8b9acae67b01526957
+   :alt: Codacy - Quality
+   :target: https://www.codacy.com/gh/MathisFederico/LearnRL/dashboard?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=MathisFederico/LearnRL&amp;utm_campaign=Badge_Grade
 
-   import gym
+.. image:: https://app.codacy.com/project/badge/Coverage/b4c3818135484e8b9acae67b01526957
+   :alt: Codacy - Coverage
+   :target: https://www.codacy.com/gh/MathisFederico/LearnRL/dashboard?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=MathisFederico/LearnRL&amp;utm_campaign=Badge_Coverage
 
-   env = gym.make('FrozenLake-v0', is_slippery=True)
-   agent = StandardAgent(env.observation_space, env.action_space,
-                        exploration=1, exploration_decay=1e-4)
-
-   pg = rl.Playground(env, agent)
-   pg.fit(2000, verbose=1)
-
-And very modular and customizable !
-For example we can overide the evaluation method (how future rewards are expected)
-and/or the control method (how a decision is made based on action value):
-
-.. code-block:: python
-
-   import learnrl as rl
-   from learnrl.agents import StandardAgent
-
-   import gym
-
-   env = gym.make('FrozenLake-v0', is_slippery=True)
-
-   class MyEvaluation(rl.Evaluation):
-
-      """ MyEvaluation uses ... to approximate the expected return at each step. """
-
-      def __init__(self, **kwargs):
-         super().__init__(name="myevaluation", **kwargs)
-
-      def eval(self, reward, done, next_observation, action_values, action_visits, control):
-         ...
-         return expected_futur_reward
-      
-   class MyControl(rl.Control):
-
-      """ MyControl will make the policy given action values Q (and action visits N) """
-
-      def __init__(self, exploration=1, **kwargs):
-         super().__init__(exploration=exploration, name="mycontrol", **kwargs)
-         self.need_action_visit = True # This is optional, here to ensure that N is given
-
-      def policy(self, Q, N=None):
-         ...
-         return p
+LearnRL is a library to use and learn reinforcement learning.
+It's also a community off supportive enthousiasts loving to share and build RL-based AI projects !
+We would love to help you make projects with LearnRL, so join us `on Discord <https://discord.gg/z9dd4s5>`_ !
 
-   evaluation = MyEvaluation(learning_rate=1e-2)
-   control = MyControl(exploration=1, exploration_decay=1e-4)
+About LearnRL
+-------------
 
-   agent = StandardAgent(env.observation_space, env.action_space,
-                        evaluation=evaluation, control=control)
+LearnRL is a framework to use and learn reinforcement learning with a wandb integration for a good visualisation !  
+Our motto is clean, sharable and readable Agents !  
+As such, you can plug and play agents on any environment, but also look how agents are built to learn !  
 
-   pg = rl.Playground(env, agent)
-   pg.fit(2000, verbose=1)
+Also, LearnRL is cross platform compatible ! That's why no agents are built-in learnrl itself, but you can check:
+   - `LearnRL for Tensorflow <https://github.com/MathisFederico/LearnRL-Tensorflow>`_
+   - `LearnRL for Pytorch <https://github.com/MathisFederico/LearnRL-Pytorch>`_
 
-You can of course build your own Agent and/or Environment from scratch !
+You can build and run your own Agent in a clear and sharable manner !
 
 .. code-block:: python
 
@@ -90,22 +49,54 @@ You can of course build your own Agent and/or Environment from scratch !
 
       def remember(self, observation, action, reward, done, next_observation=None, info={}, **param):
          """ How the Agent will remember experiences """
-         pass
+         ...
 
-   env = gym.make('FrozenLake-v0', is_slippery=True)
+   env = gym.make('FrozenLake-v0', is_slippery=True) # This could be any gym Environment !
    agent = MyAgent(env.observation_space, env.action_space)
 
    pg = rl.Playground(env, agent)
    pg.fit(2000, verbose=1)
 
-Note that 'learn' and 'remember' are optional, so this can also be used for baselines.
+Note that 'learn' and 'remember' are optional, so this framework can also be used for baselines !
+
+Of course, you can logs any custom metrics that your Agent/Env gives you and even chose how to aggregate them through episodes or cycles:
+See the `metric codes <https://learnrl.readthedocs.io/en/latest/callbacks.html#metric-codes>`_ for more details.
+
+.. code-block:: python
+
+   metrics=[
+        ('reward~env-rwd', {'steps': 'sum', 'episode': 'sum'}),
+        ('handled_reward~reward', {'steps': 'sum', 'episode': 'sum'}),
+        'value_loss~vloss',
+        'actor_loss~aloss',
+        'exploration~exp'
+    ]
+
+   pg.fit(2000, verbose=1, metrics=metrics)
+
+The Playground will allow you to have clean logs adapted to your will with the verbose parameter:
+  - Verbose 1 : episodes cycles - If your environment makes a lot of quick episodes.
+      .. image:: _static/images/logs-verbose-1.png
+
+  - Verbose 2 : episode - To log each individual episode.
+      .. image:: _static/images/logs-verbose-2.png
+
+  - Verbose 3 : steps cycles - If your environment makes a lot of quick steps but has long episodes.
+      .. image:: _static/images/logs-verbose-3.png
+
+  - Verbose 4 : step - To log each individual step.
+      .. image:: _static/images/logs-verbose-4.png
+
+  - Verbose 5 : detailled step - To debug each individual step (with observations, actions, ...).
+      .. image:: _static/images/logs-verbose-5.png
+
+The Playground also allows you to add Callbacks with ease, for example the WandbCallback to have a nice dashboard !
+TODO: Show wandb logging
 
 Features
 --------
 
-- Build highly configurable classic reinforcement learning agents in few lines of code.
-- Train your Agents on any Gym or custom environment.
-- Use this API to create your own agents and environments (even multiplayer!) with great compatibility.
+- Use this API to create your own agents and environments (even multiplayer!) with great compatibility and visualisation.
 
 Installation
 ------------
@@ -114,18 +105,30 @@ Install LearnRL by running::
 
    pip install learnrl
 
+Get started
+-----------
+
+Create:
+   - TODO: Numpy DQN tutorial
+   - TODO: Tensorflow tutorials
+   - TODO: Pytorch tutorials
+
+Visualize:
+   - TODO: Tensorboard visualisation tutorial
+   - TODO: Wandb visualisation tutorial
+   - TODO: Wandb sweep tutorial
 
 Table Of Content
 ----------------
 
 .. toctree::
+   :caption: API
    :maxdepth: 2
 
    core
-   agents
-   environments
    callbacks
 
+
 Contribute
 ----------
 
@@ -142,3 +145,7 @@ License
 
 | The project is licensed under the GNU LGPLv3 license.
 | See LICENCE, COPYING and COPYING.LESSER for more details.
+
+.. |gym.Env| replace:: `environment <http://gym.openai.com/docs/#environments>`__
+.. |gym.Space| replace:: `space <http://gym.openai.com/docs/#spaces>`__
+.. |hash| replace:: `perfect hash functions <https://en.wikipedia.org/wiki/Perfect_hash_function>`__
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 4fe20e6..386481d 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -3,3 +3,4 @@ gym
 sphinx-autoapi
 autoapi
 sphinx_rtd_theme
+sphinx-autodoc-typehints
\ No newline at end of file
diff --git a/learnrl/__init__.py b/learnrl/__init__.py
index 69b3737..3931c64 100644
--- a/learnrl/__init__.py
+++ b/learnrl/__init__.py
@@ -1,14 +1,11 @@
 # LearnRL a python library to learn and use reinforcement learning
 # Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
 
-__version__ = "0.2.1"
+"""LearnRL a python library to learn and log reinforcement learning"""
+
+__version__ = "1.0.0"
 
 from learnrl.agent import Agent
 from learnrl.envs import TurnEnv
 from learnrl.playground import Playground, DoneHandler, RewardHandler
-from learnrl.memory import Memory
-
-from learnrl.estimators import Estimator
-from learnrl.control import Control
-from learnrl.evaluation import Evaluation
 from learnrl.callbacks import Callback
diff --git a/learnrl/agent.py b/learnrl/agent.py
index 3578397..4cf353c 100644
--- a/learnrl/agent.py
+++ b/learnrl/agent.py
@@ -1,58 +1,56 @@
 # LearnRL a python library to learn and use reinforcement learning
 # Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
 
+"""Agent to represent any learning or static agent"""
+
+from typing import Dict, Union
+from abc import abstractmethod
+
+import numpy as np
+
+
 class Agent():
 
-    """ A general structure for reinforcement learning agents    
-    
-    It uses by default a :class:`~learnl.memory.Memory`
-
-    Attributes
-    ----------
-
-        name: :class:`str`
-        memory: :class:`~learnl.memory.Memory`
-            The Agent's memory
-    
-    """
-
-    def act(self, observation, greedy=False):
-        """ How the :ref:`Agent` act given an observation
-        
-        Parameters
-        ----------
-            observation:
-                The observation given by the |gym.Env|
-            greedy: bool
-                If True, act greedely (without exploration)
+    """A general structure for any learning agent."""
+
+    @abstractmethod
+    def act(self, observation, greedy: bool=False) -> Union[int, float, np.ndarray]:
+        """How the :ref:`Agent` act given an observation.
+
+        Args:
+            observation: The observation given by the |gym.Env|.
+            greedy: If True, act greedely (without exploration).
 
         """
-        raise NotImplementedError
+        raise NotImplementedError('Agent.act must be user-defined by subclassing.')
 
-    def learn(self):
-        """ How the :ref:`Agent` learns from his experiences 
-        
-        Returns
-        -------
-            logs: dict
-                The agent learning logs.
+    def learn(self) -> Dict[str, Union[int, float, np.ndarray]]:
+        """How the :ref:`Agent` learns from his experiences.
+
+        Returns:
+            logs: The agent learning logs (Has to be numpy or python).
 
         """
         return {}
 
-    def remember(self, observation, action, reward, done, next_observation=None, info={}, **param):
-        """ How the :ref:`Agent` will remember experiences
-        
-        Often, the agent will use a |hash| to store observations efficiently
+    def remember(self,
+            observation,
+            action,
+            reward,
+            done,
+            next_observation=None,
+            info=None,
+            **param
+        ):
+        """How the :ref:`Agent` will remember experiences.
+
+        Often, the agent will use a |hash| to store observations efficiently.
 
-        Example
-        -------
+        Example:
             >>>  self.memory.remember(self.observation_encoder(observation),
             ...                       self.action_encoder(action),
-            ...                       reward, done, 
-            ...                       self.observation_encoder(next_observation), 
+            ...                       reward, done,
+            ...                       self.observation_encoder(next_observation),
             ...                       info, **param)
 
-            Where self.memory is an instance of :class:`~learnl.memory.Memory`.
         """
-        pass
diff --git a/learnrl/agents/__init__.py b/learnrl/agents/__init__.py
deleted file mode 100644
index 266302a..0000000
--- a/learnrl/agents/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-from learnrl.agents.standard import StandardAgent
-from learnrl.agents.random import RandomAgent
diff --git a/learnrl/agents/random/__init__.py b/learnrl/agents/random/__init__.py
deleted file mode 100644
index 87edad6..0000000
--- a/learnrl/agents/random/__init__.py
+++ /dev/null
@@ -1,24 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-from learnrl import Agent
-from learnrl.control import Random
-
-class RandomAgent(Agent):
-
-    def __init__(self, observation_space, action_space, control=None, evaluation=None, action_values=None, action_visits=None, **kwargs):
-        super().__init__()
-        self.name = "random"
-        self.observation_space = observation_space
-        self.action_space = action_space
-        self.control = Random()
-    
-    def remember(self, observation, action, reward, done, next_observation=None, info={}, **param):
-        pass
-
-    def act(self, observation, greedy=False):
-        return self.action_space.sample()
-    
-    def learn(self):
-        pass
-
diff --git a/learnrl/agents/standard/__init__.py b/learnrl/agents/standard/__init__.py
deleted file mode 100644
index c22846d..0000000
--- a/learnrl/agents/standard/__init__.py
+++ /dev/null
@@ -1,183 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-from learnrl.evaluation import QLearning
-from learnrl.control import Greedy
-from learnrl.estimators import TableEstimator
-from learnrl import Agent
-from learnrl.memory import Memory
-
-from gym import spaces
-import numpy as np
-
-import time
-
-class StandardAgent(Agent):
-
-    """  A standard structure of RL agents
-
-    Build with parts :class:`~learnrl.control.Control` for policy,
-    :class:`~learnrl.evaluation.Evaluation` for future rewards estimations
-    and :class:`~learnrl.estimators.Estimator` for action_value estimation.
-    
-    Parameters
-    ----------
-        observation_space: |gym.Space| 
-            The observation_space of the environement that the agent will observe
-        action_space: |gym.Space|
-            The action_space of the environement that the agent will act on
-        control: :class:`~learnrl.control.Control`
-            Control object to define policy from :attr:`~learnrl.agents.standard.StandardAgent.action_values` *(default is 0.1-Greedy)*
-        evaluation:  :class:`~learnrl.evaluation.Evaluation`, optional
-            Evaluation object to update :attr:`~learnrl.agents.standard.StandardAgent.action_values` from agent :class:`~learnrl.memory.Memory` *(default is QLearning)*
-        action_values: :class:`~learnrl.estimators.Estimator`, optional
-            Known as Q(s,a), this represent the expected return (futur rewards) given that
-            the agent took the action a in the state s. *(default is a Table)*
-        action_visits: :class:`~learnrl.estimators.Estimator`, optional
-            Known as N(s,a), this represent the number of times that
-            the agent took the action a in the state s.
-        online: bool, optional
-            If False, wait the end of the episode to learn, else learn every step.
-        sample_size: int, optional
-            Maximum number of samples to take from memory to perform a learning step. If 0, takes all memory.
-        sample_method: str, optional
-            The sampling used, see :meth:`~learnrl.memory.Memory.sample` for details.
-        forget_after_update: bool, optional
-            If True, forget all past memory after a learning step.
-        step_skip: int, optional
-            The number of steps between learn calls.
-    
-    Attributes
-    ----------
-        All args and kwargs becomes attributes.
-
-        name: str
-            The name of the DeepRLAgent
-
-    """
-    
-    def __init__(self, observation_space, action_space, control=None, evaluation=None, action_values=None, action_visits=None, **kwargs):
-        memory_len = kwargs.pop('memory_len', 10000)
-        self.memory = Memory(memory_len)
-
-        self.online = kwargs.pop('online', True)
-
-        self.sample_size = kwargs.pop('sample_size', 128)
-        default_sample_method = 'naive_uniform' if self.online else 'last'
-        self.sample_method = kwargs.pop('sample_method', default_sample_method)
-
-        self.step_skip = kwargs.pop('step_skip', 0)
-
-        self.control = control if control is not None else Greedy(**kwargs)
-        self.evaluation = evaluation if evaluation is not None else QLearning(online=self.online, **kwargs)
-
-        self.name = f'standard_{self.control.name}_{self.evaluation.name}_{kwargs}'
-
-        self.action_values = action_values if action_values is not None else TableEstimator(observation_space, action_space, **kwargs)
-        self.action_visits = action_visits
-        if action_visits is None:
-            if self.control.need_action_visit:
-                self.action_visits = TableEstimator(observation_space, action_space, learning_rate=1, dtype=np.uint64)
-
-        self.forget_after_update = kwargs.pop('forget_after_update', isinstance(self.action_values, TableEstimator))
-
-        self.observation_space = observation_space
-        self.action_space = action_space
-        self._step_to_skip = self.step_skip
-    
-    def act(self, observation, greedy=False):
-        """ Gives the agent action when an observation is given.
-        
-        This function defines the agent behavior.
-
-        Parameters
-        ----------
-            observation: :class:`numpy.ndarray` or int
-                The observation given by the |gym.Env| step to the agent
-            greedy: bool
-                Whether the agent will act greedly or not (turn off exploration)
-            
-        Return
-        ------
-            action_taken: sample of |gym.Space|
-                The action taken by the agent
-        """
-        observation = self.action_values.observation_encoder(observation)
-        if isinstance(observation, np.ndarray):
-            observation = observation[np.newaxis, :]
-        else: observation = np.array([observation])
-        
-        policy = self.control._get_policy(observation, self.action_values, self.action_visits, greedy=greedy)[0]
-        action_id = np.random.choice(range(len(policy)), p=policy)
-        action_taken = self.action_values.action_decoder(action_id)
-        
-        if action_taken not in self.action_space:
-            raise ValueError(f'Action taken should be in action_space, but {action_taken} was not in {self.action_space}. '
-                             f'Check the action_decoder of your action_values Estimator')
-        return action_taken
-    
-    def remember(self, observation, action, reward, done, next_observation=None, info={}):
-        self.memory.remember(self.action_values.observation_encoder(observation),
-                             self.action_values.action_encoder(action),
-                             reward,
-                             done,
-                             self.action_values.observation_encoder(next_observation),
-                             info)
-
-    def learn(self, target_control=None):
-        logs = {}
-
-        # Skip learning step if step_skip > 0
-        skip = self._skip()
-        logs.update({'skip': skip})
-        if skip:
-            return
-
-        # Take a sample of experiences
-        observations, actions, reward, done, next_observation, _ = self.memory.sample(sample_size=self.sample_size, method=self.sample_method)
-
-        # Do the online/offline learning
-        if self.online or done[-1]:
-            # Get expected rewards from self.evaluation
-            target_control = target_control if target_control is not None else self.control
-            logs.update({'target_control': target_control})
-
-            expected_rewards = self.evaluation._get_evaluation(reward, done, next_observation, self.action_values, self.action_visits, target_control=target_control)
-
-            # Update estimators
-            action_values_logs = self.action_values._fit(observations, actions, Y=expected_rewards)
-            logs.update({'action_value': action_values_logs})
-            if self.action_visits: 
-                action_visits_logs = self.action_visits._fit(observations, actions, Y=self.action_visits(observations, actions)+1)
-                logs.update({'action_visit': action_visits_logs})
-        
-            # Forget memory if needed
-            logs.update({'forget': self.forget_after_update})
-            if self.forget_after_update:
-                self.memory.forget()
-        
-            # Update hyperparameters
-            self.control.update_exploration()
-            logs.update({'exploration': self.control.exploration})
-            self.action_values.update_learning_rate()
-            logs.update({'learning_rate': self.action_values.learning_rate})
-        
-        return logs
-        
-    def _skip(self):
-        if self.step_skip > 0:
-            if self._step_to_skip > 0:
-                self._step_to_skip -= 1
-                return True
-            else:
-                self._step_to_skip = self.step_skip
-                return False
-    
-    def __repr__(self):
-        return self.name
-
-    def __str__(self):
-        return self.name
-    
-    def __call__(self, observation, greedy=False):
-        return self.act(observation, greedy=greedy)
diff --git a/learnrl/callbacks/__init__.py b/learnrl/callbacks/__init__.py
index d6a02d5..f59df2f 100644
--- a/learnrl/callbacks/__init__.py
+++ b/learnrl/callbacks/__init__.py
@@ -1,27 +1,29 @@
 # LearnRL a python library to learn and use reinforcement learning
 # Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
 
-from learnrl.callbacks.callback import Callback, CallbackList
+"""Callbacks to perform actions while running a playground"""
+
+import importlib.util
 
+from learnrl.callbacks.callback import Callback, CallbackList
 from learnrl.callbacks.logging_callback import LoggingCallback, MetricList, Metric
 from learnrl.callbacks.logger import Logger
 
-import importlib
 tensorflow_spec = importlib.util.find_spec('tensorflow')
-
 if tensorflow_spec is not None:
     from learnrl.callbacks.tensorboard import TensorboardCallback
 else:
     class TensorboardCallback():
+        """Dummy TensorboardCallback when tensorflow in not installed"""
         def __init__(self, log_dir):
             raise ImportError('Missing dependency : tensorflow >= 2.0.0')
 
 wandb_spec = importlib.util.find_spec('wandb')
-
 if wandb_spec is not None:
-    from learnrl.callbacks.wandb import WandbLogger
+    from learnrl.callbacks.wandb import WandbCallback
 else:
-    class WandbLogger():
-        def __init__(self):
-            raise ImportError('Missing dependency : wandb >= 0.10')
+    class WandbCallback():
+        """Dummy WandbCallback when wandb in not installed"""
 
+        def __init__(self, run=None):
+            raise ImportError('Missing dependency : wandb >= 0.10')
diff --git a/learnrl/callbacks/callback.py b/learnrl/callbacks/callback.py
index 010a0e3..c6a54f4 100644
--- a/learnrl/callbacks/callback.py
+++ b/learnrl/callbacks/callback.py
@@ -1,118 +1,203 @@
 # LearnRL a python library to learn and use reinforcement learning
 # Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
 
+"""Callback abstract classes"""
+
 import time
 
 class Callback():
 
     """ An object to call functions while the :class:`~learnrl.playground.Playground` is running.
     You can define the custom functions `on_{position}` where position can be :
-    
+
     >>> run_begin
-    ...     cycle_begin
+    ...     episodes_cycle_begin
     ...         episode_begin
-    ...             step_begin
-    ...             # env.step()
-    ...             step_end
+    ...             steps_cycle_begin
+    ...                 step_begin
+    ...                 # env.step()
+    ...                 step_end
+    ...             steps_cycle_end
     ...         # done==True
     ...         episode_end
-    ...     cycle_end
+    ...     episodes_cycle_end
     ... run_end
-    
+
     """
 
+    def __init__(self):
+        self.params = {}
+        self.playground = None
+
     def set_params(self, params):
+        """Sets run parameters"""
         self.params = params
-    
+
     def set_playground(self, playground):
+        """Sets reference to the used playground"""
         self.playground = playground
 
-    def on_step_begin(self, step, logs=None):
-        pass
+    def on_step_begin(self, step: int, logs: dict=None):
+        """Triggers on each step beginning
 
-    def on_step_end(self, step, logs=None):
-        pass
-    
-    def on_episode_begin(self, episode, logs=None):
-        pass
+        Args:
+            step: current step.
+            logs: current logs.
 
-    def on_episode_end(self, episode, logs=None):
-        pass
+        """
 
-    def on_cycle_begin(self, episode, logs=None):
-        pass
+    def on_step_end(self, step: int, logs: dict=None):
+        """Triggers on each step end
 
-    def on_cycle_end(self, episode, logs=None):
-        pass
+        Args:
+            step: current step.
+            logs: current logs.
 
-    def on_run_begin(self, logs=None):
-        pass
+        """
 
-    def on_run_end(self, logs=None):
-        pass
+    def on_steps_cycle_begin(self, step: int, logs: dict=None):
+        """Triggers on each step cycle beginning
+
+        Args:
+            step: current step.
+            logs: current logs.
+
+        """
+
+    def on_steps_cycle_end(self, step: int, logs: dict=None):
+        """Triggers on each step cycle end
+
+        Args:
+            step: current step.
+            logs: current logs.
+
+        """
+
+    def on_episode_begin(self, episode: int, logs: dict=None):
+        """Triggers on each episode beginning
+
+        Args:
+            episode: current episode.
+            logs: current logs.
+
+        """
+
+    def on_episode_end(self, episode: int, logs: dict=None):
+        """Triggers on each episode end
+
+        Args:
+            episode: current episode.
+            logs: current logs.
+
+        """
+
+    def on_episodes_cycle_begin(self, episode: int, logs: dict=None):
+        """Triggers on each episode cycle beginning
+
+        Args:
+            episode: current episode.
+            logs: current logs.
 
+        """
+
+    def on_episodes_cycle_end(self, episode: int, logs: dict=None):
+        """Triggers on each episode cycle end
+
+        Args:
+            episode: current episode.
+            logs: current logs.
+
+        """
+
+    def on_run_begin(self, logs: dict=None):
+        """Triggers on each run beginning
+
+        Args:
+            logs: current logs.
+
+        """
+
+    def on_run_end(self, logs: dict=None):
+        """Triggers on run end
+
+        Args:
+            logs: current logs.
+
+        """
+
+
+class CallbackList(Callback):
+    """ An wrapper to use a list of :class:`Callback`.
+
+    Call all concerned callbacks While the :class:`~learnrl.playground.Playground` is running.
 
-class CallbackList():
-    """ An wrapper to use a list of :class:`Callback` while the :class:`~learnrl.playground.Playground` is running.
     """
-    
-    def __init__(self, callbacks=[]):
+
+    def __init__(self, callbacks=()):
+        super().__init__()
         self.callbacks = callbacks
-        self.params = {}
-        self.playground = None
-    
+
     def set_params(self, params):
         self.params = params
         for callback in self.callbacks:
             callback.set_params(params)
-    
+
     def set_playground(self, playground):
         self.playground = playground
         for callback in self.callbacks:
             callback.set_playground(playground)
-        
+
     def _call_key_hook(self, key, hook, value=None, logs=None):
-        """ Helper func for {step|episode|cycle|run}_{begin|end} methods. """
+        """ Helper func for {step|episode|steps_cycle|episodes_cycle|run}_{begin|end} methods. """
 
         if len(self.callbacks) == 0:
             return
-        
+
         hook_name = f'on_{key}_{hook}'
         t_begin_name = f't_{key}_begin'
         dt_name = f'dt_{key}'
 
         if hook == 'begin':
             setattr(self, t_begin_name, time.time())
-        
+
         if hook == 'end':
             t_begin = getattr(self, t_begin_name)
-            dt = time.time() - t_begin
-            setattr(self, dt_name, dt)
+            elapsed_time = time.time() - t_begin
+            setattr(self, dt_name, elapsed_time)
             if logs is not None:
-                logs.update({dt_name: dt})
-        
+                logs.update({dt_name: elapsed_time})
+
         for callback in self.callbacks:
             step_hook = getattr(callback, hook_name)
             if logs is not None:
-                step_hook(logs) if value is None else step_hook(value, logs)
-    
+                if value is None:
+                    step_hook(logs)
+                else:
+                    step_hook(value, logs)
+
     def on_step_begin(self, step, logs=None):
         self._call_key_hook('step', 'begin', step , logs)
 
     def on_step_end(self, step, logs=None):
         self._call_key_hook('step', 'end', step , logs)
-    
+
+    def on_steps_cycle_begin(self, step, logs=None):
+        self._call_key_hook('steps_cycle', 'begin', step , logs)
+
+    def on_steps_cycle_end(self, step, logs=None):
+        self._call_key_hook('steps_cycle', 'end', step , logs)
+
     def on_episode_begin(self, episode, logs=None):
         self._call_key_hook('episode', 'begin', episode , logs)
 
     def on_episode_end(self, episode, logs=None):
         self._call_key_hook('episode', 'end', episode , logs)
 
-    def on_cycle_begin(self, episode, logs=None):
-        self._call_key_hook('cycle', 'begin', episode , logs)
+    def on_episodes_cycle_begin(self, episode, logs=None):
+        self._call_key_hook('episodes_cycle', 'begin', episode , logs)
 
-    def on_cycle_end(self, episode, logs=None):
-        self._call_key_hook('cycle', 'end', episode , logs)
+    def on_episodes_cycle_end(self, episode, logs=None):
+        self._call_key_hook('episodes_cycle', 'end', episode , logs)
 
     def on_run_begin(self, logs=None):
         self._call_key_hook('run', 'begin', logs=logs)
diff --git a/learnrl/callbacks/logger.py b/learnrl/callbacks/logger.py
index 6f8d133..725a8b3 100644
--- a/learnrl/callbacks/logger.py
+++ b/learnrl/callbacks/logger.py
@@ -1,6 +1,9 @@
 # LearnRL a python library to learn and use reinforcement learning
 # Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
 
+"""Default Logger to show progress in console"""
+
+from typing import List, Union
 import numpy as np
 
 from learnrl.callbacks import LoggingCallback, MetricList, Metric
@@ -8,87 +11,113 @@
 
 class Logger(LoggingCallback):
 
-    """ Default logger in every :class:`~learnrl.playground.Playground` run
-    
+    """Default logger in every :class:`~learnrl.playground.Playground` run.
+
     This will print relevant informations in console.
 
-    You can regulate the flow of informations with the argument `verbose` in :meth:`~learnrl.playground.Playground.run` directly :
+    You can regulate the flow of informations with the argument `verbose`
+    in :meth:`~learnrl.playground.Playground.run` directly :
      - 0 is silent (nothing will be printed)
-     - 1 is only cycles of episodes (aggregated metrics over multiple episodes)
+     - 1 is cycles of episodes (aggregated metrics over multiple episodes)
      - 2 is every episode (aggregated metrics over all steps)
-     - 3 is every step (scalar metrics of all steps)
-     - 4 is every step detailed (all metrics of all steps)
-    
-    You can also replace it with you own :class:`~learnrl.callbacks.Logger` with the argument `logger` in :meth:`~learnrl.playground.Playground.run`.
-     - To build you own logger, you have to chose what metrics will be displayed and how will metrics be aggregated over steps/episodes/cycles.
-       To do that, see the :ref:`metric_code` format.
-    
-    Parameters
-    ----------
-        detailed_step_only_metrics: list(str)
-            Metrics to display only on detailed steps.
-        step_only_metrics: list(str)
-            Metrics to display only on steps.
-        step_metrics: list(str)
-            Metrics to display on steps and to aggregate in episodes
-        episode_only_metrics: list(str)
-            Metrics to display only on episodes.
-        episode_metrics: list(str)
-            Metrics to display on episodes and to aggregate in episodes_cycles.
-        cycle_only_metrics: list(str)
-            Metrics to display only on cycles.
-        cycle_metrics: list(str)
-            Metrics to display on cycles (aggregated from episodes and/or steps).
-        titles_on_top: bool
-            If true, titles will be displayed on top and not at every line in the console.
-    
+     - 3 is cycles of steps (aggregated metrics over some steps)
+     - 4 is every step
+     - 5 is every step detailed (all metrics of all steps)
+
+    You can also replace it with you own :class:`~learnrl.callbacks.Logger`,
+    with the argument `logger` in :meth:`~learnrl.playground.Playground.run`.
+
+    To build you own logger, you have to chose what metrics will be displayed
+    and how will metrics be aggregated over steps/episodes/cycles.
+    To do that, see the :ref:`metric_code` format.
+
     """
 
-    def __init__(self, 
-                detailed_step_only_metrics=['observation', 'action', 'next_observation'],
-                step_metrics=['reward', 'loss', 'exploration~exp', 'learning_rate~lr'],
-                episode_only_metrics=[], 
-                episode_metrics=['reward.sum', 'loss', 'exploration~exp.last', 'learning_rate~lr.last'],
-                cycle_metrics=['reward', 'loss', 'exploration~exp.last', 'learning_rate~lr.last'],
-                cycle_only_metrics=[],
-                titles_on_top=True
-                ):
-        
+    def __init__(self,
+            metrics: List[Union[str, tuple]]=None,
+            detailed_step_metrics: List[str]=None,
+            episode_only_metrics: List[str]=None,
+            titles_on_top: bool=True):
+
+        """Default logger in every :class:`~learnrl.playground.Playground` run.
+
+        Args:
+            metrics: Metrics to display and how to aggregate them.
+            detailed_step_metrics: Metrics to display only on detailed steps.
+            episode_only_metrics: Metrics to display only on episodes.
+            titles_on_top: If true, titles will be displayed on top and not at every line.
+
+        """
+
+        if metrics is None:
+            metrics = [
+                ('reward', {'steps_cycles': 'sum', 'episode': 'sum'}),
+                'dt_step~'
+            ]
+        if detailed_step_metrics is None:
+            detailed_step_metrics = ['observation', 'action', 'next_observation']
+        if episode_only_metrics is None:
+            episode_only_metrics = ['dt_episode~']
+
         super().__init__(
-            detailed_step_only_metrics=detailed_step_only_metrics,
-            step_metrics=step_metrics,
+            metrics=metrics,
+            detailed_step_metrics=detailed_step_metrics,
             episode_only_metrics=episode_only_metrics,
-            episode_metrics=episode_metrics,
-            cycle_metrics=cycle_metrics,
-            cycle_only_metrics=cycle_only_metrics
         )
 
+        self.titles_on_top = titles_on_top
+        self.verbose = None
+
         self._bar_lenght = 100
         self._number_window = 9
-        self.titles_on_top = titles_on_top
+        self._number_window_time = 5
+        self._n_digits_episodes = None
 
     def on_step_begin(self, step, logs=None):
-        text = self._get_step_text(step, pad=self.verbose == 3)
-        if self.verbose == 3:
+        super().on_step_begin(step, logs=logs)
+
+        text = self._get_step_text(step, pad=self.verbose in [3, 4])
+        if self.verbose == 4:
             print(text, end=' | ')
-        elif self.verbose == 4:
+        elif self.verbose == 5:
             self._print_bar('-', text)
-    
-    def on_step_end(self, step, logs={}):
-        super().on_step_end(step, logs=logs)
 
+    def on_step_end(self, step, logs=None):
+        super().on_step_end(step, logs=logs)
         agent_id = logs.get('agent_id')
 
-        if self.verbose > 2:
-            sep, end = (' | ', '\n') if self.verbose == 3 else (None, '')
+        if self.verbose >= 4:
+            sep, end = (' | ', '\n') if self.verbose == 4 else (None, '')
             if self.n_agents > 1:
                 print(f"Agent {agent_id}", end=sep)
-            self._print_metrics(self.step_metrics, 'logs', agent_id=agent_id, logs=logs, sep=sep, end=end)                
+            self._print_metrics(self.step_metrics,
+                agent_id=agent_id, logs=logs, sep=sep, end=end)
 
-        if self.verbose == 4:
-            self._print_metrics(self.detailed_step_only_metrics, 'logs', logs=logs)
+        if self.verbose == 5:
+            self._print_metrics(self.detailed_step_metrics, logs=logs)
             self._print_bar('-')
 
+    def on_steps_cycle_begin(self, step, logs=None):
+        super().on_steps_cycle_begin(step, logs=logs)
+
+        if self.verbose == 4 and self.titles_on_top:
+            step_text = self._get_step_text(0)
+            self._print_titles(self.step_metrics, offset=' ' * len(step_text) + ' |', end='\n')
+
+    def on_steps_cycle_end(self, step, logs=None):
+        super().on_steps_cycle_end(step, logs=logs)
+
+        if self.verbose == 3:
+            text = self._get_step_text(step, pad=True)
+            print(text, end=' | ')
+
+            agent_id = logs.get('agent_id')
+            sep, end = (' | ', '\n')
+            if self.n_agents > 1:
+                print(f"Agent {agent_id}", end=sep)
+            self._print_metrics(self.steps_cycle_metrics, prefix='steps_cycle',
+                                agent_id=agent_id, logs=logs, sep=sep, end=end)
+
     def on_episode_begin(self, episode, logs=None):
         super().on_episode_begin(episode, logs=logs)
 
@@ -100,30 +129,36 @@ def on_episode_begin(self, episode, logs=None):
                 self._print_bar('=', text)
                 if self.verbose == 3 and self.titles_on_top:
                     step_text = self._get_step_text(0)
-                    self._print_titles(self.step_metrics, offset=' ' * len(step_text) + ' |', end='\n')
+                    self._print_titles(
+                        self.step_metrics,
+                        offset=' ' * len(step_text) + ' |', end='\n'
+                    )
 
     def on_episode_end(self, episode, logs=None):
         super().on_episode_end(episode, logs=logs)
 
         if self.verbose >= 3:
             print()
-            print("Episode " + self._get_episode_text(episode), end=' | ')                
-        
+            print("Episode " + self._get_episode_text(episode), end=' | ')
+
         if self.verbose >= 2:
             if self.titles_on_top and self.n_agents > 1:
                 self._print_titles(self.episode_metrics, prefix='\n', offset=' '*12 + '|')
-            
+
             for agent_id in range(self.n_agents):
                 if self.n_agents > 1:
                     print(end=f'\n    Agent {agent_id} | ')
-                
+
                 titles_on_top = False if self.verbose >= 3 else None
 
-                self._print_metrics(self.episode_metrics, 'attrs', prefix='episode',
+                self._print_metrics(self.episode_metrics, prefix='episode',
                     agent_id=agent_id, sep=' | ', titles_on_top=titles_on_top
                 )
-            
-            self._print_metrics(self.episode_only_metrics, 'logs', logs=logs, sep=' | ', titles_on_top=False)
+
+            self._print_metrics(
+                self.episode_only_metrics,
+                logs=logs, sep=' | ', titles_on_top=False
+            )
 
         if self.verbose > 1:
             print()
@@ -131,49 +166,62 @@ def on_episode_end(self, episode, logs=None):
         if self.verbose > 2:
             self._print_bar('=')
 
-    def on_cycle_end(self, episode, logs=None):
-         if self.verbose == 1:
+    def on_episodes_cycle_begin(self, episode, logs=None):
+        super().on_episodes_cycle_begin(episode, logs=logs)
+
+        if self.verbose == 2 and self.titles_on_top:
+            eps_text = "Episode " + self._get_episode_text(episode)
+            self._print_titles(self.episode_metrics,
+                               prefix='\n',
+                               offset=len(eps_text) * ' ' + ' |',
+                               end='\n')
+
+    def on_episodes_cycle_end(self, episode, logs=None):
+        super().on_episodes_cycle_end(episode, logs=logs)
+
+        if self.verbose == 1:
             print("Episode " + self._get_episode_text(episode), end=' | ')
-            
-            self._print_metrics(self.cycle_only_metrics, 'logs', logs=logs, sep=' | ')
 
             if self.titles_on_top and self.n_agents > 1:
-                self._print_titles(self.cycle_metrics, prefix='\n', offset=' '*12 + '|')
-            
+                self._print_titles(self.episodes_cycle_metrics, prefix='\n', offset=' '*12 + '|')
+
             for agent_id in range(self.n_agents):
-                if self.n_agents > 1: print(end=f'\n    Agent {agent_id} | ')
-                self._print_metrics(self.cycle_metrics, 'attrs', prefix='cycle', agent_id=agent_id, sep=' | ')
-            
+                if self.n_agents > 1:
+                    print(end=f'\n    Agent {agent_id} | ')
+                self._print_metrics(self.episodes_cycle_metrics,
+                    prefix='episodes_cycle', agent_id=agent_id, sep=' | ')
+
             print()
 
     def on_run_begin(self, logs=None):
         super().on_run_begin(logs=logs)
-        self.n_digits_episodes = int(np.log10(self.params['episodes'])) + 1
+        self._n_digits_episodes = int(np.log10(self.params['episodes'])) + 1
         self.verbose = self.params['verbose']
 
-        if self.verbose == 4:
+        if self.verbose == 5:
             self.titles_on_top = False
 
-        if self.titles_on_top and self.n_agents == 1 and self.verbose in (1, 2):
+        if self.titles_on_top and self.n_agents == 1 and self.verbose == 1:
             offset_len = len("Episode " + self._get_episode_text(0))
-            metrics_to_print = self.cycle_metrics if self.verbose == 1 else self.episode_metrics
-            self._print_titles(metrics_to_print, prefix='', offset=' ' * offset_len + ' |', end='\n')
-
-    def on_run_end(self, logs=None):
-        pass
+            if self.verbose == 1:
+                metrics_to_print = self.episodes_cycle_metrics
+            else:
+                metrics_to_print = self.episode_metrics
+            self._print_titles(metrics_to_print,
+                prefix='', offset=' ' * offset_len + ' |', end='\n')
 
-    def _print_metrics(self, metric_list:MetricList, source:str, prefix=None, agent_id=None,
+    def _print_metrics(self, metric_list:MetricList, prefix=None, agent_id=None,
                              logs=None, sep=None, end='', titles_on_top=None):
         """ Print a metric list """
         titles_on_top = titles_on_top if titles_on_top is not None else self.titles_on_top
         for metric in metric_list:
             value = self._get_value(metric, prefix, agent_id, logs)
-            
+
             pass_metric = False
             if isinstance(value, str) and value == 'N/A':
                 pass_metric = not titles_on_top
                 value = ''
-            
+
             if not pass_metric:
                 self._print_metric(metric, value, titles_on_top, end=sep)
 
@@ -184,33 +232,38 @@ def _print_metric(self, metric:Metric, metric_value, titles_on_top, **kwargs):
         if metric.name.startswith('dt_'):
             level = metric.name.split('_')[1]
             metric_display = self._get_time_text(metric_value, level)
-        
+
         elif isinstance(metric_value, (int, float, np.float32, np.float64)):
             if metric_value == 0 or (abs(metric_value) < 1000 and abs(metric_value) > 0.001):
                 metric_display = f"{metric_value:.3g}"
             else:
                 metric_display = f"{metric_value:.2E}"
-        
+
         elif isinstance(metric_value, np.ndarray):
             metric_display = '\n' + str(metric_value)
-        
+
         else:
             metric_display = str(metric_value)
-        
+
         space = ' ' if len(metric.surname) > 0 else ''
         prefix = '' if titles_on_top else f"{metric.surname.capitalize()}" + space
 
         metric_display = metric_display + (self._number_window - len(metric_display)) * ' '
         print(prefix + metric_display, **kwargs)
-    
+
     def _print_titles(self, metric_list:MetricList, prefix='', offset='', end=''):
         """ Print the titles of the metric list """
         print(prefix, end=offset)
         for metric in metric_list:
-            if not metric.name.startswith('dt'):
-                surname = metric.surname.capitalize()[:self._number_window]
-                display_name = self._text_in_middle(' ', surname, self._number_window+2)
-                print(display_name, end='|')
+            if not metric.name.startswith('dt_'):
+                capitalized_surname = metric.surname.capitalize()[0] + metric.surname[1:]
+                surname = capitalized_surname[:self._number_window]
+                title_lenght = self._number_window+2
+            else:
+                surname = metric.name[3:].capitalize() + ' time'
+                title_lenght = self._number_window_time + 9
+            display_name = self._text_in_middle(' ', surname, lenght=title_lenght)
+            print(display_name, end='|')
         print(end=end)
 
     def _print_bar(self, line, text=None, **kwargs):
@@ -225,39 +278,44 @@ def _text_in_middle(line, text, lenght):
         semibar_lenght = (lenght - len(text)) // 2 - 1
         odd = (lenght - len(text)) % 2 == 1
         return line * semibar_lenght + f' {text} ' + line * (semibar_lenght + 1*odd)
-    
+
     def _get_episode_text(self, episode):
         """ Get the display text for an episode """
         text = f"{episode+1}"
-        text = " " * (self.n_digits_episodes - len(text)) + text
+        text = " " * (self._n_digits_episodes - len(text)) + text
         text += f"/{self.params['episodes']}"
         return text
 
-    def _get_step_text(self, step, pad=True):
+    @staticmethod
+    def _get_step_text(step, step_end=None, pad=True):
         text = f'Step {step+1}'
-        if pad and len(text) < 11:
-            text += ' ' * (11 - len(text))
+        if step_end is not None:
+            text += f'-{step_end+1}'
+
+        if pad and len(text) < 13:
+            text += ' ' * (13 - len(text))
         return text
-    
-    def _get_time_text(self, dt, unit):
+
+    def _get_time_text(self, elapsed_time, unit):
         """ Get the display text for a time mesurment """
-        if unit == 'episode':
-            unit = 'eps'
-        if dt < 1e-9:
-            return 'N/A         '
-        elif dt < 1e-6:
-            time_display = f'{dt/1e-9:.01f}'
+        unit = unit[:4]
+        if isinstance(elapsed_time, str):
+            return elapsed_time
+        if elapsed_time < 1e-9:
+            time_display = '<1'
             time_unit = 'ns'
-        elif dt < 1e-3:
-            time_display = f'{dt/1e-6:.01f}'
+        elif elapsed_time < 1e-6:
+            time_display = f'{elapsed_time/1e-9:.01f}'
+            time_unit = 'ns'
+        elif elapsed_time < 1e-3:
+            time_display = f'{elapsed_time/1e-6:.01f}'
             time_unit = 'us'
-        elif dt < 1:
-            time_display = f'{dt/1e-3:.01f}'
+        elif elapsed_time < 1:
+            time_display = f'{elapsed_time/1e-3:.01f}'
             time_unit = 'ms'
         else:
-            time_display = f'{dt:.01f}'
+            time_display = f'{elapsed_time:.01f}'
             time_unit = 's '
-        
-        margin = (5 - len(time_display)) * ' '
-        return margin + f'{time_display}{time_unit}/{unit}'
 
+        margin = (self._number_window_time - len(time_display)) * ' '
+        return margin + f'{time_display}{time_unit}/{unit}'
diff --git a/learnrl/callbacks/logging_callback.py b/learnrl/callbacks/logging_callback.py
index bfe514a..96ef6ef 100644
--- a/learnrl/callbacks/logging_callback.py
+++ b/learnrl/callbacks/logging_callback.py
@@ -1,13 +1,16 @@
 # LearnRL a python library to learn and use reinforcement learning
 # Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
 
+"""LoggingCallback tracks aggregated metrics during runs"""
+
+from typing import List, Union
 from learnrl.callbacks.callback import Callback
 
 class Metric():
 
     """ An helper object to represent a metric via a str metric_code """
 
-    def __init__(self, metric_code):
+    def __init__(self, metric_code: str):
         split_code = metric_code.split('.')
         self.code = metric_code
         fullname = split_code[0]
@@ -15,12 +18,12 @@ def __init__(self, metric_code):
         self.name = split_name[0]
         self.surname = split_name[1] if len(split_name) > 1 else self.name
         self.operator = split_code[1] if len(split_code) > 1 else 'avg'
-    
+
     def __eq__(self, other):
         if isinstance(other, str):
             return self.name == other
         return self.name == other.name
-    
+
     def __str__(self):
         return self.name
 
@@ -32,36 +35,34 @@ class MetricList():
 
     """ An helper object to represent a list of metrics """
 
-    def __init__(self, metric_list):
+    def __init__(self, metric_list: List[Metric]):
+        if metric_list is None:
+            metric_list = []
         self.metric_list = [self.to_metric(m) for m in metric_list]
         self.metric_names = [m.name for m in self.metric_list]
-    
-    def __getitem__(self, index):
+
+    def __getitem__(self, index: int):
         return self.metric_list[index]
-    
-    def __contains__(self, metric:Metric):
+
+    def __contains__(self, metric: Metric):
         return metric.name in self.metric_names
 
-    def __add__(self, metric_list):
-        new_list = self.metric_list + [self.to_metric(m) for m in metric_list if m not in self]
+    def __add__(self, metric_list: Union[str, Metric, List[Metric], List[str], 'MetricList']):
+        if isinstance(metric_list, (str, Metric)):
+            metric_list = [metric_list]
+        metric_list = [self.to_metric(m) for m in metric_list]
+        new_list = self.metric_list + [m for m in metric_list if m not in self]
         return MetricList(new_list)
 
-    def append(self, metric:Metric):
-        if metric not in self:
-            self.metric_list += [self.to_metric(metric)]
-            self.metric_names += [metric.name]
-        else:
-            raise ValueError(f'{metric} already in MetricList')
-    
     def __str__(self):
         return str(self.metric_list)
-    
+
     @staticmethod
-    def to_metric(metric):
+    def to_metric(metric: Union[str, Metric]):
+        """Convert a metric_code into a true Metric"""
         if isinstance(metric, Metric):
             return metric
-        else:
-            return Metric(metric)
+        return Metric(metric)
 
 
 class LoggingCallback(Callback):
@@ -69,32 +70,60 @@ class LoggingCallback(Callback):
     """ Generic class for tracking metrics """
 
     def __init__(self,
-                detailed_step_only_metrics=['observation', 'action', 'next_observation'],
-                step_metrics=['reward', 'loss', 'exploration~exp', 'learning_rate~lr', 'dt_step~'],
-                episode_only_metrics=['dt_episode~'], 
-                episode_metrics=['reward.sum', 'loss', 'exploration~exp.last', 'learning_rate~lr.last', 'dt_step~'],
-                cycle_only_metrics=['dt_episode~'],
-                cycle_metrics=['reward', 'loss', 'exploration~exp.last', 'learning_rate~lr.last', 'dt_step~'],
-                ):
+            metrics: List[Union[str, tuple]]=None,
+            detailed_step_metrics: List[str]=None,
+            episode_only_metrics: List[str]=None
+        ):
+        super().__init__()
+        self.detailed_step_metrics = MetricList(detailed_step_metrics)
+        self.episode_only_metrics = MetricList(episode_only_metrics)
 
-        self.detailed_step_only_metrics = MetricList(detailed_step_only_metrics)
-        self.step_metrics = MetricList(step_metrics)
+        metric_lists = self._extract_lists(metrics)
+        self.step_metrics = MetricList(metric_lists[0])
+        self.steps_cycle_metrics = MetricList(metric_lists[1])
+        self.episode_metrics = MetricList(metric_lists[2])
+        self.episodes_cycle_metrics = MetricList(metric_lists[3])
 
-        self.episode_only_metrics = MetricList(episode_only_metrics)
-        self.episode_metrics = MetricList(episode_metrics)
+        self.n_agents = None
 
-        self.cycle_only_metrics = MetricList(cycle_only_metrics)
-        self.cycle_metrics = MetricList(cycle_metrics)
+    @staticmethod
+    def _extract_lists(metrics):
+        """Extract the metric lists from a nested metrics argument"""
+        step_metrics = []
+        steps_cycle_metrics = []
+        episode_metrics = []
+        episodes_cycle_metrics = []
+
+        if metrics is not None:
+            if not isinstance(metrics, (tuple, list)):
+                raise ValueError('Wrong metrics format. See metrics codes in documentation.')
+
+            for metric in metrics:
+                if isinstance(metric, (tuple, list)):
+                    metric_name, ops = metric
+                elif isinstance(metric, str):
+                    metric_name = metric
+                    if '.' in metric_name:
+                        metric_name, opertor_for_all = metric_name.split('.')
+                        ops = {key:opertor_for_all for key in ('steps', 'episode', 'episodes')}
+                    else:
+                        ops = {}
+
+                step_metrics.append(metric_name)
+                steps_cycle_metrics.append('.'.join((metric_name, ops.get('steps', 'avg'))))
+                episode_metrics.append('.'.join((metric_name, ops.get('episode', 'avg'))))
+                episodes_cycle_metrics.append('.'.join((metric_name, ops.get('episodes', 'avg'))))
+        return step_metrics, steps_cycle_metrics, episode_metrics, episodes_cycle_metrics
 
     def _reset_attr(self, attr_name, operator):
-        """ Reset a metric attribute based on the metric operator """
+        """Reset a metric attribute based on the metric operator"""
         setattr(self, attr_name, 'N/A')
         if operator == 'avg':
             metric_seen = attr_name + '_seen'
             setattr(self, metric_seen, 0)
 
     def _update_attr(self, attr_name, last_value, operator):
-        """ Update a metric attribute based on the metric operator and the last metric value """
+        """Update a metric attribute based on the metric operator and the last metric value"""
         previous_value = getattr(self, attr_name)
 
         if previous_value == 'N/A':
@@ -105,27 +134,23 @@ def _update_attr(self, attr_name, last_value, operator):
             new_seen = getattr(self, metric_seen) + 1
             setattr(self, metric_seen, new_seen)
             setattr(self, attr_name, previous_value + (last_value - previous_value) / new_seen)
-
         elif operator == 'sum':
             setattr(self, attr_name, last_value + previous_value)
-
         elif operator == 'last':
             setattr(self, attr_name, last_value)
-
         else:
             raise ValueError(f'Unknowed operator {operator}')
 
     @staticmethod
     def _get_attr_name(prefix, metric, agent_id=None):
-        """ Get the attribute name of a metric/agent couple """
+        """Get the attribute name of a metric/agent couple"""
         if agent_id:
             return '_'.join((prefix, "agent" + str(agent_id), metric.name))
-        else:
-            return '_'.join((prefix, metric.name))
+        return '_'.join((prefix, metric.name))
 
     @staticmethod
     def _extract_metric_from_logs(metric_name, logs, agent_id=None):
-        """ Extract the last value of a metric from logs (specified for an agent or not) """
+        """Extract the last value of a metric from logs (specified for an agent or not)"""
 
         def _search_logs(metric_name, logs:dict):
             if logs is None or metric_name in logs:
@@ -154,7 +179,16 @@ def _search_logs(metric_name, logs:dict):
         value = _logs.get(metric_name) if _logs is not None else 'N/A'
         return value
 
-    def _get_value(self, metric:Metric, prefix=None, agent_id=None, logs=None):
+    def _get_value(self, metric:Metric, prefix: str=None, agent_id: int=None, logs: dict=None):
+        """Gather the value of a specific metric
+
+        Args:
+            metric: The metric to look for.
+            prefix: The level of aggregation to look for.
+            agent_id: The specific agent to look into.
+            logs: The logs to look into if the value was not found.
+
+        """
         # Search for prefix-wise attr
         if prefix is not None:
             src_name = self._get_attr_name(prefix, metric, agent_id)
@@ -165,11 +199,18 @@ def _get_value(self, metric:Metric, prefix=None, agent_id=None, logs=None):
         # If not found or no source, search in logs directly
         if src_value == 'N/A' and logs is not None:
             src_value = self._extract_metric_from_logs(metric.name, logs, agent_id)
-        
+
         return src_value
 
-    def _update_metrics(self, metric_list:MetricList, target_prefix, source_prefix=None, logs=None, agent_id=None, reset=False):
-        """ Update the logger attributes based on a metric list """
+    def _update_metrics(self,
+            metric_list:MetricList,
+            target_prefix,
+            source_prefix=None,
+            logs=None,
+            agent_id=None,
+            reset=False
+        ):
+        """Update the logger attributes based on a metric list"""
         for metric in metric_list:
             target_name = self._get_attr_name(target_prefix, metric, agent_id)
 
@@ -182,29 +223,36 @@ def _update_metrics(self, metric_list:MetricList, target_prefix, source_prefix=N
             # Update attr if a value was found
             if src_value != 'N/A':
                 self._update_attr(target_name, src_value, metric.operator)
-    
-    def _update_metrics_all_agents(self, metric_list:MetricList, target_prefix, **kwargs):
-        if self.n_agents > 1:
-            for agent_id in range(self.n_agents):
-                self._update_metrics(metric_list, target_prefix, agent_id=agent_id, **kwargs)
-        else:
-            self._update_metrics(metric_list, target_prefix, agent_id=0, **kwargs)
 
-    def on_step_end(self, step, logs={}):
-        agent_id = logs.get('agent_id') if self.n_agents > 1 else None
-        self._update_metrics(self.episode_metrics, 'episode', logs=logs, agent_id=agent_id)
+    def _update_metrics_all_agents(self,
+            metric_list:MetricList,
+            target_prefix,
+            **kwargs
+        ):
+        """Update the logger attributes of all agents based on a metric list"""
+        for agent_id in range(self.n_agents):
+            self._update_metrics(metric_list, target_prefix, agent_id=agent_id, **kwargs)
 
-    def on_episode_begin(self, episode, logs=None):
-        self._update_metrics_all_agents(self.episode_metrics, 'episode', logs=logs, reset=True)
+    def on_run_begin(self, logs=None):
+        self.n_agents = len(self.playground.agents)
 
-    def on_episode_end(self, episode, logs=None):
-        if self.params['verbose'] == 1:
-            self._update_metrics_all_agents(self.cycle_metrics, 'cycle', source_prefix='episode', logs=logs)
+    def on_episodes_cycle_begin(self, episode, logs=None):
+        self._update_metrics_all_agents(self.episodes_cycle_metrics,
+            'episodes_cycle', source_prefix='episode', logs=logs, reset=True)
 
-    def on_cycle_begin(self, episode, logs=None):
-        if self.params['verbose'] == 1:
-            self._update_metrics_all_agents(self.cycle_metrics, 'cycle', source_prefix='episode', logs=logs, reset=True)
+    def on_episode_begin(self, episode, logs=None):
+        self._update_metrics_all_agents(self.episode_metrics,
+            'episode', logs=logs, reset=True)
 
-    def on_run_begin(self, logs=None):
-        self.n_agents = len(self.playground.agents)
+    def on_steps_cycle_begin(self, step, logs=None):
+        self._update_metrics_all_agents(self.steps_cycle_metrics,
+            'steps_cycle', logs=logs, reset=True)
 
+    def on_step_end(self, step, logs=None):
+        agent_id = logs.get('agent_id') if self.n_agents > 1 else None
+        self._update_metrics(self.episode_metrics, 'episode', logs=logs, agent_id=agent_id)
+        self._update_metrics_all_agents(self.steps_cycle_metrics, 'steps_cycle', logs=logs)
+
+    def on_episode_end(self, episode, logs=None):
+        self._update_metrics_all_agents(self.episodes_cycle_metrics,
+            'episodes_cycle', source_prefix='episode', logs=logs)
diff --git a/learnrl/callbacks/tensorboard.py b/learnrl/callbacks/tensorboard.py
index 808717a..fb62bf8 100644
--- a/learnrl/callbacks/tensorboard.py
+++ b/learnrl/callbacks/tensorboard.py
@@ -1,7 +1,15 @@
 # LearnRL a python library to learn and use reinforcement learning
 # Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
 
+"""TensorboardCallback to log metrics into tensorboard
+
+https://www.tensorflow.org/tensorboard/get_started
+
+"""
+
 import os, datetime
+from typing import List, Tuple, Dict, Any, Union
+
 import tensorflow as tf
 
 from learnrl.callbacks.logging_callback import LoggingCallback, MetricList
@@ -9,32 +17,30 @@
 
 class TensorboardCallback(LoggingCallback):
 
-    """ Tensorboard logger if tensorflow is installed.
-
-    Parameters
-    ---------
-        log_dir: :class:`str`
-            Directory to store runs logs.
-        run_name: :class:`str`
-            Specify a run name for Tensorboard, default is a datetime.
-        step_metrics: list(str)
-            Metrics to log on steps and to aggregate in episodes.
-        episode_metrics: list(str)
-            Metrics to log on episodes and to aggregate in episodes_cycles.
-        cycle_metrics: list(str)
-            Metrics to log on cycles (aggregated from episodes and/or steps).
-    """
-
-    def __init__(self, log_dir, run_name=None,
-                 step_metrics=['reward', 'loss', 'exploration~exp', 'learning_rate~lr'],
-                 episode_metrics=['reward.sum', 'loss', 'exploration~exp.last', 'learning_rate~lr.last'],
-                 cycle_metrics=['reward', 'loss', 'exploration~exp.last', 'learning_rate~lr.last'],
-                 ):
+    """Tensorboard logger will log metrics in tensorboard."""
+
+    def __init__(self,
+            log_dir: str,
+            run_name: str=None,
+            metrics: List[Union[str, tuple]]=None,
+            detailed_step_metrics: List[str]=None,
+            episode_only_metrics: List[str]=None
+        ):
+        
+        """Tensorboard logger will log metrics in tensorboard.
+
+        Args:
+            log_dir: Directory to store runs logs.
+            run_name: Specify a run name for Tensorboard, default is a datetime.
+            metrics: Metrics to display and how to aggregate them.
+            detailed_step_metrics: Metrics to display only on detailed steps.
+            episode_only_metrics: Metrics to display only on episodes.
+        """
         
         super().__init__(
-            step_metrics=step_metrics,
-            episode_metrics=episode_metrics,
-            cycle_metrics=cycle_metrics
+            metrics=metrics,
+            detailed_step_metrics=detailed_step_metrics,
+            episode_only_metrics=episode_only_metrics
         )
 
         if run_name is None:
@@ -52,24 +58,20 @@ def on_episode_end(self, episode, logs=None):
         super().on_episode_end(episode, logs=logs)
         self._update_tensorboard(episode + 1, 'episode', self.episode_metrics)
 
-    def on_cycle_end(self, episode, logs=None):
-        super().on_cycle_end(episode, logs=logs)
-        if self.params['verbose'] == 1:
-            self._update_tensorboard(episode + 1, 'cycle', self.cycle_metrics)
-
-    def _update_tensorboard(self, step, prefix, metrics_list:MetricList, logs=None):
+    def _update_tensorboard(self,
+            step: int,
+            prefix: str,
+            metrics_list:MetricList,
+            logs: Dict[str, Any]=None
+        ):
         """ Helper function for writing new values to Tensorboard summary.
 
-        Parameters
-        ----------
-            step: :class:`int`
-                Step value for the Tensorboard summary.
-            prefix: :class:`str`
-                Prefix for metrics name.
-            metrics_list: :class:`~learnrl.callbacks.MetricList`
-                Metrics to write.
-            logs: :class:`dict` (default is None)
-                If set to None, metrics value will be searched in attributes, otherwise they will be searched in logs.
+        Args:
+            step: Step value for the Tensorboard summary.
+            prefix: Prefix for metrics name.
+            metrics_list: Metrics to write.
+            logs: If None, metrics value will be searched in attributes.
+                Otherwise they will be searched in logs.
 
             """
         with self.writer.as_default(): #pylint: disable=all
diff --git a/learnrl/callbacks/wandb.py b/learnrl/callbacks/wandb.py
index 29e3122..e371526 100644
--- a/learnrl/callbacks/wandb.py
+++ b/learnrl/callbacks/wandb.py
@@ -1,45 +1,166 @@
 # LearnRL a python library to learn and use reinforcement learning
 # Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
 
-import wandb
+"""WandbCallback to log metrics into weight&biases
+
+https://wandb.ai/site
+
+"""
+
+from typing import List, Union
 
+import wandb
 from learnrl.callbacks.logging_callback import LoggingCallback, MetricList
 
 
-class WandbLogger(LoggingCallback):
-    
-    def __init__(self,
-                 step_metrics=['reward', 'loss', 'exploration~exp', 'learning_rate~lr'],
-                 episode_metrics=['reward.sum', 'loss', 'exploration~exp.last', 'learning_rate~lr.last'],
-                 cycle_metrics=['reward', 'loss', 'exploration~exp.last', 'learning_rate~lr.last'],
-                 ):
-        
+def construct_panel_configs(metric_name:str, title:str=None):
+    """Construct a custom wandb query to build the custom graphs."""
+
+    histories = [
+        {
+            "name": "history",
+            "args": [{"name": "keys",
+                        "value": [
+                            "step",
+                            "steps_cycle",
+                            "episode",
+                            "episodes_cycle",
+                            f"step_{metric_name}",
+                            f"steps_cycle_{metric_name}",
+                            f"episode_{metric_name}",
+                            f"episodes_cycle_{metric_name}",
+                        ]
+                    }],
+                "fields": [],
+        }
+    ]
+
+    user_query = {
+        "queryFields": [
+            {
+                "name": "runSets",
+                "args": [{"name": "runSets", "value": "${runSets}"}],
+                "fields": [
+                    {"name": "id", "fields": []},
+                    {"name": "name", "fields": []},
+                    {"name": "_defaultColorIndex", "fields": []}
+                ] + histories,
+            }
+        ],
+    }
+
+    title = title or metric_name.capitalize()
+    fields = {
+        "title": title,
+        "x1": "step",
+        "x2": "steps_cycle",
+        "x3": "episode",
+        "x4": "episodes_cycle",
+        "y1": f"step_{metric_name}",
+        "y2": f"steps_cycle_{metric_name}",
+        "y3": f"episode_{metric_name}",
+        "y4": f"episodes_cycle_{metric_name}",
+    }
+
+    return {
+        "userQuery": user_query,
+        "panelDefId": 'mathisfederico/learnrl-chart',
+        "transform": {"name": "tableWithLeafColNames"},
+        "fieldSettings": fields
+    }
+
+class WandbCallback(LoggingCallback):
+
+    """ WandbCallback will log metrics to wandb."""
+
+    def __init__(self, run,
+            metrics: List[Union[str, tuple]]=None,
+            detailed_step_metrics: List[str]=None,
+            episode_only_metrics: List[str]=None
+        ):
+
+        """ WandbCallback will log metrics to wandb.
+
+        See https://wandb.ai.
+
+        Args:
+            metrics: list(str) or list(tuple)
+                Metrics to display and how to aggregate them.
+            detailed_step_metrics: list(str)
+                Metrics to display only on detailed steps.
+            episode_only_metrics: list(str)
+                Metrics to display only on episodes.
+        """
+
         super().__init__(
-            step_metrics=step_metrics,
-            episode_metrics=episode_metrics,
-            cycle_metrics=cycle_metrics
+            metrics=metrics,
+            detailed_step_metrics=detailed_step_metrics,
+            episode_only_metrics=episode_only_metrics
         )
 
-        self.step = 1 # Internal step counter
-        
-    def on_step_end(self, step, logs={}):
+        self.metrics = metrics
+
+        self.run = run
+        self.chart_is_init = False
+
+        self.step = 0
+        self.steps_cycle = 0
+        self.episode = 0
+        self.episodes_cycle = 0
+
+    def on_step_begin(self, step, logs=None):
+        super().on_step_begin(step, logs=logs)
+
+    def on_step_end(self, step, logs=None):
         super().on_step_end(step, logs=logs)
-        self._update_wandb(self.step, 'step', self.step_metrics, logs)
+
+        if not self.chart_is_init:
+            self._init_charts()
+            self.chart_is_init = True
+
+        self._update_wandb('step', self.step_metrics, logs)
+        self._update_wandb('steps_cycle', self.steps_cycle_metrics, logs)
+        self._update_wandb('episode', self.episode_metrics, logs)
+        self._update_wandb('episode', self.episode_only_metrics, logs)
+        self._update_wandb('episodes_cycle', self.episodes_cycle_metrics, logs)
+        wandb.log({
+            'step':self.step,
+            'episode': self.episode,
+            'steps_cycle': self.steps_cycle,
+            'episodes_cycle': self.episodes_cycle,
+        })
         self.step += 1
 
+    def on_steps_cycle_end(self, step, logs=None):
+        super().on_steps_cycle_end(step, logs=logs)
+        self.steps_cycle += 1
+
     def on_episode_end(self, episode, logs=None):
         super().on_episode_end(episode, logs=logs)
-        self._update_wandb(episode + 1, 'episode', self.episode_metrics)
+        self.episode += 1
+
+    def on_episodes_cycle_end(self, episode, logs=None):
+        super().on_episodes_cycle_end(episode, logs=logs)
+        self.episodes_cycle += 1
 
-    def on_cycle_end(self, episode, logs=None):
-        super().on_cycle_end(episode, logs=logs)
-        if self.params['verbose'] == 1:
-            self._update_wandb(episode + 1, 'cycle', self.cycle_metrics)
+    def _init_charts(self):
+        metrics_with_chart = []
+        allmetrics = self.step_metrics + self.steps_cycle_metrics + \
+            self.episode_metrics + self.episodes_cycle_metrics
+        for metric in allmetrics:
+            metric_name = metric.name
+            if not metric_name in metrics_with_chart:
+                title = f'{metric_name.capitalize()} ({metric.surname.capitalize()})'
+                panel_config = construct_panel_configs(metric_name, title)
+                panel_name = metric_name + '_panel'
+                self.run._add_panel(panel_name, 'Vega2', panel_config) # pylint: disable=protected-access
+                metrics_with_chart.append(metric_name)
 
-    def _update_wandb(self, step, prefix, metrics_list:MetricList, logs=None):
+    def _update_wandb(self, prefix, metrics_list:MetricList, logs=None):
         for agent_id in range(self.n_agents):
             for metric in metrics_list:
                 name = self._get_attr_name(prefix, metric, agent_id)
                 value = self._get_value(metric, prefix, agent_id, logs)
-                if value != 'N/A': wandb.log({name: value})
-
+                if value != 'N/A':
+                    wandb.log({name: value}, commit=False)
+        wandb.log({ 'episode': self.episode })
diff --git a/learnrl/control.py b/learnrl/control.py
deleted file mode 100644
index 6615be6..0000000
--- a/learnrl/control.py
+++ /dev/null
@@ -1,198 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-"""
-Control methodes to improve the policy based on value fonctions
-"""
-import numpy as np
-from learnrl.estimators import Estimator
-
-class Control():
-
-    """ Control base object\n
-
-    This method must be specified :
-    policy(self, action_values, action_visits=None).
-
-    Example
-    -------
-
-    >>> from learnrl.control import Control
-    ...
-    ... class MyControl(Control):
-    ...
-    ...     def __init__(self, exploration=0.1, **kwargs):
-    ...         super().__init__(exploration=exploration, name="my_control_name", **kwargs)
-    ...         self.need_action_visit = False/True
-    ...
-    ...     def policy(self, observations:np.ndarray, action_values:Estimator, action_visits:Estimator=None):
-    ...         ...
-    ...         ...
-    ...         return policy
-
-    Parameters
-    ----------
-        exploration: float
-            The initial exploration constant
-        name: str
-            The name of the control (mandatory)
-        exploration_decay: float
-            The exploration_decay (default to 0)
-        
-    Attributes
-    ----------
-        exploration: float
-            The exploration constant
-        name: str
-            The name of the control
-        decay: float
-            The exploration decay
-        need_action_visit: bool
-            True if the control needs action visit
-
-    """
-
-    def __init__(self, exploration=0, name=None, **kwargs):
-        self.exploration = exploration
-        if name is None:
-            raise ValueError("The Control object must have a name")
-        self.name = name
-        self.decay = kwargs.get('exploration_decay', 0)
-        self.need_action_visit = False
-
-    def policy(self, Q, N=None):
-        """ Return the policy of the agent given an observation
-        
-        Arguments
-        ---------
-            Q: numpy.ndarray
-                The estimator of Q(s,a), the expected futur reward if we do action 'a' in state 's'.
-                Shape is (sample_size, action_size).
-
-            N: numpy.ndarray, optional
-                The estimator of N(s,a), the number of times we did action 'a' in state 's'.
-                Shape is (sample_size, action_size).
-        
-        Return
-        ------
-            policy: numpy.ndarray
-                The probabilities of choosing every actions.
-
-        """
-        raise NotImplementedError
-
-    def _get_policy(self, observations:np.ndarray, action_values:Estimator, action_visits:Estimator=None, greedy=False):
-        if action_visits is None and self.need_action_visit:
-            raise ValueError(f"action_visits must be specified for {self.name} control")
-
-        Q = action_values(observations)
-        N = action_visits(observations) if self.need_action_visit else None
-        policies = self.policy(Q, N)
-        self._check_policy(policies)
-        return policies
-    
-    def _check_policy(self, policies):
-        if np.any(policies < 0) or np.any(policies > 1):
-            raise ValueError("Policy are not in [0, 1]")
-        prob_sums = np.sum(policies, axis=1)
-        invalid_policy = np.abs(np.sum(prob_sums - 1)) > 1e-6
-        if np.any(invalid_policy):
-            raise ValueError(f"Policy {policies[invalid_policy]} probabilities sum to {prob_sums[invalid_policy]} and not 1")
-
-    def update_exploration(self, exploration=None):
-        """ Update the exploration constant
-
-        By default, uses exploration_decay (or self.decay) to update exploration 
-        with formula :math:`exploration *= (1 - decay)`.
-
-        If this method is called with a float argument, exploration will update to that float.
-        
-        Arguments
-        ---------
-            exploration: float, optional
-                The fixed exploration constant that we set to.
-        
-        """
-        if exploration is not None:
-            self.exploration = exploration
-        else:
-            self.exploration *= (1 - self.decay)
-
-    def __str__(self):
-        return f'{self.name}_exp({self.exploration:.2f}_decay({self.decay}))'
-    
-    def __eq__(self, other):
-        same_name = self.name == other.name
-        same_exploration = self.exploration == other.exploration
-        same_decay = self.decay == other.decay
-        return same_name and same_exploration and same_decay
-
-
-class Random(Control):
-
-    """ Random control to use for tests """
-
-    def __init__(self, exploration=1, **kwargs):
-        super().__init__(exploration=exploration, name="random", **kwargs)
-
-    def policy(self, Q:np.ndarray, N:np.ndarray=None):
-        sample_size, action_size = Q.shape
-        p = np.zeros(Q.shape)
-        action = np.random.choice(action_size, size=(sample_size,), replace=True)
-        p[:, action] = 1
-        return p
-
-class Greedy(Control):
-
-    """ Greedy control
-    
-    Takes the action maximizing action_value with probability (1-exploration).
-    Else take a uniformly random action.
-
-    """
-
-    def __init__(self, exploration=0.1, **kwargs):
-        super().__init__(exploration=exploration, name="greedy", **kwargs)
-
-    def policy(self, Q:np.ndarray, N:np.ndarray=None):
-        if self.exploration < 0 or self.exploration > 1:
-            raise ValueError(f"Exploration should be in [0, 1] for greedy control but was {self.exploration}")
-            
-        best_action_id = np.argmax(Q, axis=1)
-        _, action_size = Q.shape
-        policy = np.ones_like(Q) * self.exploration / action_size
-        policy[np.arange(len(best_action_id)), best_action_id] += 1 - self.exploration
-        return policy
-
-class Ucb(Control):
-
-    """ Upper Confidence Bound control
-
-    """
-
-    def __init__(self, exploration=1, **kwargs):
-        super().__init__(exploration=exploration, name="ucb", **kwargs)
-        self.need_action_visit = True
-
-    def policy(self, Q:np.ndarray, N:np.ndarray=None):
-        best_action_id = np.argmax(Q + self.exploration * np.sqrt( np.log(1 + np.sum(N, axis=1)) / (1.0 + N)), axis=1)
-        policy = np.zeros_like(Q, dtype=np.uint8)
-        policy[np.arange(len(best_action_id)), best_action_id] = 1
-        return policy
-
-
-class Puct(Control):
-
-    """ Puct control
-
-    """
-
-    def __init__(self, exploration=1, **kwargs):
-        super().__init__(exploration=exploration, name="puct", **kwargs)
-        self.need_action_visit = True
-
-    def policy(self, Q:np.ndarray, N:np.ndarray=None):  
-        best_action_id = np.argmax(Q + self.exploration * np.sqrt( np.sum(N, axis=1) / (1.0 + N)), axis=1)
-        policy = np.zeros_like(Q, dtype=np.uint8)
-        policy[np.arange(len(best_action_id)), best_action_id] = 1
-        return policy
diff --git a/learnrl/environments/__init__.py b/learnrl/environments/__init__.py
deleted file mode 100644
index 40f54bd..0000000
--- a/learnrl/environments/__init__.py
+++ /dev/null
@@ -1,12 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-from learnrl.environments.single_agents import RdNimEnv, CatchEnv
-from learnrl.environments.multi_agents import CrossesAndNoughtsEnv
-
-# Import basic gym envs
-from gym.envs.algorithmic import *
-from gym.envs.box2d import *
-from gym.envs.classic_control import *
-from gym.envs.toy_text import *
-
diff --git a/learnrl/environments/multi_agents/__init__.py b/learnrl/environments/multi_agents/__init__.py
deleted file mode 100644
index 2ed5e53..0000000
--- a/learnrl/environments/multi_agents/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-from learnrl.environments.multi_agents.crosses_and_noughts import CrossesAndNoughtsEnv
diff --git a/learnrl/environments/multi_agents/crosses_and_noughts/__init__.py b/learnrl/environments/multi_agents/crosses_and_noughts/__init__.py
deleted file mode 100644
index da3db4e..0000000
--- a/learnrl/environments/multi_agents/crosses_and_noughts/__init__.py
+++ /dev/null
@@ -1,267 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-from gym import spaces
-import numpy as np
-from copy import deepcopy
-
-from itertools import product
-import os
-
-from learnrl.agent import Agent
-from learnrl.envs import TurnEnv
-
-class CrossesAndNoughtsGame():
-
-    """Logic of the CrossesAndNoughts game"""
-
-    def __init__(self, penality=0.01):
-        self.grid = np.zeros((3,3), dtype=np.uint8)
-        self.crosses = 1
-        self.nought = 2
-        self.isPygameInit = False
-
-    def is_valid(self, position):
-        position = np.array(position)
-        if position.ndim == 1:
-            position = np.array([position])
-
-        values = []
-        for coord in position:
-            if np.all(np.array(coord) >= 0) and np.all(np.array(coord) < 3):
-                values.append(self.grid[tuple(coord)])
-            else: values.append(0)
-        
-        values = np.array(values, dtype=np.uint8)
-        pos = np.array(position)
-        return np.logical_and(np.logical_and(np.all(pos >= 0, axis=1), np.all(pos < 3, axis=1)), values == 0)
-    
-    def getLegalActions(self):
-        actions = np.array([(0, 0), (0, 1), (0, 2),
-                            (1, 0), (1, 1), (1, 2),
-                            (2, 0), (2, 1), (2, 2)])
-        return actions[self.is_valid(actions)]
-        
-    def play(self, player, position):
-        move = tuple(position)
-        if self.is_valid(move):
-            self.grid[move] = player
-
-    def wincheck(self):
-        x = np.array([1, 1, 1], dtype=np.uint8)
-        o = np.array([2, 2, 2], dtype=np.uint8)
-        
-        # Check all rows
-        for i in range(3):
-            row = self.grid[i, :]
-            if np.all(row == x): return 1
-            if np.all(row == o): return 2
-
-        # Check all columns
-        for i in range(3):
-            col = self.grid[:, i]
-            if np.all(col == x): return 1
-            if np.all(col == o): return 2
-
-        # Check diagonal
-        diag = np.diag(self.grid)
-        if np.all(diag == x): return 1
-        if np.all(diag == o): return 2
-
-        # Check antidiagonal
-        anti_diag = np.diag(np.fliplr(self.grid))
-        if np.all(anti_diag == x): return 1
-        if np.all(anti_diag == o): return 2
-        
-        # If no one can play
-        if np.all(self.grid != 0): return 3
-        return 0
-
-    def initPygame(self, scale_factor=5):
-        import pygame
-        from pygame.transform import scale
-        
-        # pygame.quit()
-        pygame.init() # pylint: disable=E1101 
-                
-        self.scale_factor = scale_factor
-        # Open Pygame window
-        self.window = pygame.display.set_mode((200*scale_factor, 100*scale_factor))
-        # Load images
-        path = os.path.join("learnrl", "environments", "multi_agents", "crosses_and_noughts")
-        self.empty_grid_img = pygame.image.load(os.path.join(path, "images", "empty_grid.png")).convert()
-        self.empty_grid_img = scale(self.empty_grid_img, (scale_factor*self.empty_grid_img.get_width(), scale_factor*self.empty_grid_img.get_height()))
-
-        self.black_background = pygame.image.load(os.path.join(path, "images", "black_background.png")).convert()
-        self.black_background = scale(self.black_background, (scale_factor*self.black_background.get_width(), scale_factor*self.black_background.get_height()))
-        
-        cross_img = pygame.image.load(os.path.join(path, "images", "cross.png")).convert()
-        cross_img = scale(cross_img, (scale_factor*cross_img.get_width(), scale_factor*cross_img.get_height()))
-
-        nought_img = pygame.image.load(os.path.join(path, "images","nought.png")).convert()
-        nought_img = scale(nought_img, (scale_factor*nought_img.get_width(), scale_factor*nought_img.get_height()))
-
-        self.rect_size = scale_factor*cross_img.get_width()
-
-        self.images = {1:cross_img, 2:nought_img}
-
-        self.states_images = {}
-        self.window.blit(self.empty_grid_img, (0,0))
-        pygame.display.flip()
-    
-    def render(self, frame_limit):
-        
-        def getPosition(i, j):
-            pos = np.array((34*self.scale_factor*i, 34*self.scale_factor*j), dtype=int)
-            return tuple(pos)
-
-        def getNextState(action, nextActionsState): 
-            for next_state, actions in nextActionsState.items(): 
-                if action in actions: 
-                    return next_state 
-            return "key doesn't exist"
-
-        if not self.isPygameInit:
-            import pygame
-            self.initPygame()
-            self.isPygameInit = True
-
-        self.window.fill((0, 0, 0))
-        nextActionsState = self.getNextActionsState(self.grid)
-        states_micro_ids = {next_state:i for (i, next_state) in enumerate(self.getNextActionsState(self.grid))}
-        colors = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255], [64, 64, 64], [192, 192, 192]]
-
-        for i in range(3):
-            for j in range(3):
-                content = self.grid[(i, j)]
-                if content in [1, 2]:
-                    image = self.images[content]
-                    self.window.blit(image, getPosition(i, j))
-                if content == 0:
-                    action = (i, j)
-                    next_state = getNextState(action, nextActionsState)
-                    color = colors[states_micro_ids[next_state]]
-                    position = np.array(getPosition(i, j))
-                    side_size = 32*self.scale_factor
-                    position = tuple(position) + (side_size, side_size)
-                    pygame.draw.rect(self.window, color, position)
-        
-        state = self.getStateHash(self.grid)
-
-        font = pygame.font.Font(None, 12*self.scale_factor)
-        text = font.render(str(state), True, (255, 255, 255))
-        text_pos = (300*self.scale_factor // 2 - text.get_width() // 2, 100*self.scale_factor // 6 - text.get_height() // 2)
-        self.window.blit(self.black_background, (self.scale_factor*100, 0))
-        self.window.blit(text, text_pos)
-
-        pygame.display.flip()
-        if frame_limit != 0:
-            pygame.time.wait(1000//frame_limit)
-        
-    def getStateHash(self, grid):
-
-        def to_state(grid):
-            u = 0
-            for i in range(3):
-                for j in range(3):
-                    u += grid[i][j] * 3**(3*i + j)
-            return u
-
-        def flip(grid):
-            return grid[::-1]
-
-        def rot(grid):
-            flatten = grid[0] + grid[1] + grid[2]
-            newflatten = [flatten[i] for i in [2, 5, 8, 1, 4, 7, 0, 3, 6]]
-            return [newflatten[3*i:3*i+3] for i in range(3)]
-
-        all_states = []
-        tmp = deepcopy(grid.tolist())
-        for _ in range(4):
-            all_states.append(to_state(tmp))
-            tmp = rot(tmp)
-        tmp = flip(tmp)
-        for _ in range(4):
-            all_states.append(to_state(tmp))
-            tmp = rot(tmp)
-        return min(all_states)
-    
-    def getNextActionsState(self, grid):
-
-        next_states = {}
-        
-        nb_X = np.sum(grid==1)
-        nb_O = np.sum(grid==2)
-        if nb_X == nb_O: player = 1
-        else: player = 2
-        
-        legal_actions = [(i, j) for i in range(3) for j in range(3) if grid[i, j] == 0]
-
-        for action in legal_actions:
-            grid_copy = deepcopy(grid)
-            grid_copy[action] = player
-            next_state_id = self.getStateHash(grid_copy)
-            if next_state_id not in next_states:
-                next_states[next_state_id] = [action]
-            else:
-                next_states[next_state_id].append(action)
-        return next_states
-
-    def getObservation(self):
-        return np.array(self.grid)
-
-    def reset(self):
-        self.__init__()
-
-
-class CrossesAndNoughtsEnv(TurnEnv):
-    
-    def __init__(self, penality=0.1, frame_limit=0):
-        self.game = CrossesAndNoughtsGame()
-        self.action_space = spaces.MultiDiscrete((3, 3))
-        self.observation_space = spaces.MultiDiscrete(3 * np.ones((3, 3), dtype=np.int64))
-        self.frame_limit = frame_limit
-        self.penality = penality
-
-    def turn(self, observation):
-        nb_X = np.sum(observation==1)
-        nb_O = np.sum(observation==2)
-        if nb_X == nb_O: return 0
-        else: return 1
-
-    def step(self, action):
-
-        pass_turn = False
-        observation = self.game.getObservation()
-        
-        player = 1 + self.turn(observation)
-        other_player = 3 - player
-
-        winner = self.game.wincheck()
-        if winner == 0:
-            if self.game.is_valid(action):
-                self.game.play(player, action)
-            else:
-                legal_actions = self.game.getLegalActions()
-                if len(legal_actions) > 0:
-                    pass_turn = True
-        
-        if not pass_turn:
-            reward, done = 0, False
-            winner = self.game.wincheck()
-            if winner == player: reward, done = 1, False
-            elif winner == other_player: reward, done = -1, True
-            elif winner == 3: done = True
-        else: reward, done = -self.penality, False
-
-        observation = self.game.getObservation()
-
-        return observation, reward, done, {'pass_turn':pass_turn}
-    
-    def render(self):
-        self.game.render(frame_limit=self.frame_limit)
-
-    def reset(self):
-        self.game = CrossesAndNoughtsGame()
-        return self.game.getObservation()
-    
diff --git a/learnrl/environments/multi_agents/crosses_and_noughts/game.py b/learnrl/environments/multi_agents/crosses_and_noughts/game.py
deleted file mode 100644
index 0e62ad3..0000000
--- a/learnrl/environments/multi_agents/crosses_and_noughts/game.py
+++ /dev/null
@@ -1,261 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-from gym import spaces
-from learnrl.core import MultiEnv, Agent
-import numpy as np
-from copy import deepcopy
-import pygame
-from pygame.transform import scale
-from itertools import product
-import os
-
-class CrossesAndNoughtsGame():
-
-    """Logic of the CrossesAndNoughts game"""
-
-    def __init__(self):
-        self.grid = np.zeros((3,3), dtype=np.uint8)
-        self.crosses = 1
-        self.nought = 2
-        self.isPygameInit = False
-
-    def is_valid(self, position):
-        position = np.array(position)
-        if position.ndim == 1:
-            position = np.array([position])
-
-        values = []
-        for coord in position:
-            if np.all(np.array(coord) >= 0) and np.all(np.array(coord) < 3):
-                values.append(self.grid[tuple(coord)])
-            else: values.append(0)
-        
-        values = np.array(values, dtype=np.uint8)
-        pos = np.array(position)
-        return np.logical_and(np.logical_and(np.all(pos >= 0, axis=1), np.all(pos < 3, axis=1)), values == 0)
-    
-    def getLegalActions(self):
-        actions = np.array([(0, 0), (0, 1), (0, 2),
-                            (1, 0), (1, 1), (1, 2),
-                            (2, 0), (2, 1), (2, 2)])
-        return actions[self.is_valid(actions)]
-        
-    def play(self, player, position):
-        move = tuple(position)
-        if self.is_valid(move):
-            self.grid[move] = player
-
-    def wincheck(self):
-        x = np.array([1, 1, 1], dtype=np.uint8)
-        o = np.array([2, 2, 2], dtype=np.uint8)
-        
-        # Check all rows
-        for i in range(3):
-            row = self.grid[i, :]
-            if np.all(row == x): return 1
-            if np.all(row == o): return 2
-
-        # Check all columns
-        for i in range(3):
-            col = self.grid[:, i]
-            if np.all(col == x): return 1
-            if np.all(col == o): return 2
-
-        # Check diagonal
-        diag = np.diag(self.grid)
-        if np.all(diag == x): return 1
-        if np.all(diag == o): return 2
-
-        # Check antidiagonal
-        anti_diag = np.diag(np.fliplr(self.grid))
-        if np.all(anti_diag == x): return 1
-        if np.all(anti_diag == o): return 2
-        
-        # If no one can play
-        if np.all(self.grid != 0): return 3
-        return 0
-
-    def initPygame(self, scale_factor=5):
-        # pygame.quit()
-        pygame.init() # pylint: disable=E1101 
-                
-        self.scale_factor = scale_factor
-        # Open Pygame window
-        self.window = pygame.display.set_mode((200*scale_factor, 100*scale_factor))
-        # Load images
-        path = os.path.join("envs", "single_agents_envs", "random_crosses_and_noughts")
-        self.empty_grid_img = pygame.image.load(os.path.join(path, "images", "empty_grid.png")).convert()
-        self.empty_grid_img = scale(self.empty_grid_img, (scale_factor*self.empty_grid_img.get_width(), scale_factor*self.empty_grid_img.get_height()))
-
-        self.black_background = pygame.image.load(os.path.join(path, "images", "black_background.png")).convert()
-        self.black_background = scale(self.black_background, (scale_factor*self.black_background.get_width(), scale_factor*self.black_background.get_height()))
-        
-        cross_img = pygame.image.load(os.path.join(path, "images", "cross.png")).convert()
-        cross_img = scale(cross_img, (scale_factor*cross_img.get_width(), scale_factor*cross_img.get_height()))
-
-        nought_img = pygame.image.load(os.path.join(path, "images","nought.png")).convert()
-        nought_img = scale(nought_img, (scale_factor*nought_img.get_width(), scale_factor*nought_img.get_height()))
-
-        self.rect_size = scale_factor*cross_img.get_width()
-
-        self.images = {1:cross_img, 2:nought_img}
-
-        self.states_images = {}
-        self.window.blit(self.empty_grid_img, (0,0))
-        pygame.display.flip()
-    
-    def render(self, frame_limit):
-
-        def getPosition(i, j):
-            pos = np.array((34*self.scale_factor*i, 34*self.scale_factor*j), dtype=int)
-            return tuple(pos)
-
-        def getNextState(action, nextActionsState): 
-            for next_state, actions in nextActionsState.items(): 
-                if action in actions: 
-                    return next_state 
-            return "key doesn't exist"
-
-        if not self.isPygameInit:
-            self.initPygame()
-            self.isPygameInit = True
-
-        self.window.fill((0, 0, 0))
-        nextActionsState = self.getNextActionsState(self.grid)
-        states_micro_ids = {next_state:i for (i, next_state) in enumerate(self.getNextActionsState(self.grid))}
-        colors = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255], [64, 64, 64], [192, 192, 192]]
-
-        for i in range(3):
-            for j in range(3):
-                content = self.grid[(i, j)]
-                if content in [1, 2]:
-                    image = self.images[content]
-                    self.window.blit(image, getPosition(i, j))
-                if content == 0:
-                    action = (i, j)
-                    next_state = getNextState(action, nextActionsState)
-                    color = colors[states_micro_ids[next_state]]
-                    position = np.array(getPosition(i, j))
-                    side_size = 32*self.scale_factor
-                    position = tuple(position) + (side_size, side_size)
-                    pygame.draw.rect(self.window, color, position)
-        
-        state = self.getStateHash(self.grid)
-
-        font = pygame.font.Font(None, 12*self.scale_factor)
-        text = font.render(str(state), True, (255, 255, 255))
-        text_pos = (300*self.scale_factor // 2 - text.get_width() // 2, 100*self.scale_factor // 6 - text.get_height() // 2)
-        self.window.blit(self.black_background, (self.scale_factor*100, 0))
-        self.window.blit(text, text_pos)
-
-        pygame.display.flip()
-        if frame_limit != 0:
-            pygame.time.wait(1000//frame_limit)
-        
-    def getStateHash(self, grid):
-
-        def to_state(grid):
-            u = 0
-            for i in range(3):
-                for j in range(3):
-                    u += grid[i][j] * 3**(3*i + j)
-            return u
-
-        def flip(grid):
-            return grid[::-1]
-
-        def rot(grid):
-            flatten = grid[0] + grid[1] + grid[2]
-            newflatten = [flatten[i] for i in [2, 5, 8, 1, 4, 7, 0, 3, 6]]
-            return [newflatten[3*i:3*i+3] for i in range(3)]
-
-        all_states = []
-        tmp = deepcopy(grid.tolist())
-        for _ in range(4):
-            all_states.append(to_state(tmp))
-            tmp = rot(tmp)
-        tmp = flip(tmp)
-        for _ in range(4):
-            all_states.append(to_state(tmp))
-            tmp = rot(tmp)
-        return min(all_states)
-    
-    def getNextActionsState(self, grid):
-
-        next_states = {}
-        
-        nb_X = np.sum(grid==1)
-        nb_O = np.sum(grid==2)
-        if nb_X == nb_O: player = 1
-        else: player = 2
-        
-        legal_actions = [(i, j) for i in range(3) for j in range(3) if grid[i, j] == 0]
-
-        for action in legal_actions:
-            grid_copy = deepcopy(grid)
-            grid_copy[action] = player
-            next_state_id = self.getStateHash(grid_copy)
-            if next_state_id not in next_states:
-                next_states[next_state_id] = [action]
-            else:
-                next_states[next_state_id].append(action)
-        return next_states
-
-    def getObservation(self):
-        return np.array(self.grid)
-
-    def reset(self):
-        self.__init__()
-
-
-class CrossesAndNoughtsEnv(MultiEnv):
-    
-    def __init__(self, frame_limit=0):
-        self.game = CrossesAndNoughtsGame()
-        self.action_space = spaces.MultiDiscrete((3, 3))
-        self.observation_space = spaces.MultiDiscrete(3 * np.ones((3, 3), dtype=np.int64))
-        self.frame_limit = frame_limit
-
-    def turn(self, observation):
-        nb_X = np.sum(observation==1)
-        nb_O = np.sum(observation==2)
-        if nb_X == nb_O: return 0
-        else: return 1
-
-    def step(self, action):
-
-        pass_turn = False
-        observation = self.game.getObservation()
-        
-        player = 1 + self.turn(observation)
-        other_player = 3 - player
-
-        winner = self.game.wincheck()
-        if winner == 0:
-            if self.game.is_valid(action):
-                self.game.play(player, action)
-            else:
-                legal_actions = self.game.getLegalActions()
-                if len(legal_actions) > 0:
-                    pass_turn = True
-        
-        if not pass_turn:
-            reward, done = 0, False
-            winner = self.game.wincheck()
-            if winner == player: reward, done = 1, False
-            elif winner == other_player: reward, done = -1, True
-            elif winner == 3: done = True
-        else: reward, done = -1, False
-
-        observation = self.game.getObservation()
-
-        return observation, reward, done, {'pass_turn':pass_turn}
-    
-    def render(self):
-        self.game.render(frame_limit=self.frame_limit)
-
-    def reset(self):
-        self.game = CrossesAndNoughtsGame()
-        return self.game.getObservation()
-    
diff --git a/learnrl/environments/multi_agents/crosses_and_noughts/images/black_background.png b/learnrl/environments/multi_agents/crosses_and_noughts/images/black_background.png
deleted file mode 100644
index 1d1644a..0000000
Binary files a/learnrl/environments/multi_agents/crosses_and_noughts/images/black_background.png and /dev/null differ
diff --git a/learnrl/environments/multi_agents/crosses_and_noughts/images/cross.png b/learnrl/environments/multi_agents/crosses_and_noughts/images/cross.png
deleted file mode 100644
index 94b593a..0000000
Binary files a/learnrl/environments/multi_agents/crosses_and_noughts/images/cross.png and /dev/null differ
diff --git a/learnrl/environments/multi_agents/crosses_and_noughts/images/empty_grid.png b/learnrl/environments/multi_agents/crosses_and_noughts/images/empty_grid.png
deleted file mode 100644
index c0a1af8..0000000
Binary files a/learnrl/environments/multi_agents/crosses_and_noughts/images/empty_grid.png and /dev/null differ
diff --git a/learnrl/environments/multi_agents/crosses_and_noughts/images/nought.png b/learnrl/environments/multi_agents/crosses_and_noughts/images/nought.png
deleted file mode 100644
index f0e669f..0000000
Binary files a/learnrl/environments/multi_agents/crosses_and_noughts/images/nought.png and /dev/null differ
diff --git a/learnrl/environments/multi_agents/nim/game.py b/learnrl/environments/multi_agents/nim/game.py
deleted file mode 100644
index 252ee2e..0000000
--- a/learnrl/environments/multi_agents/nim/game.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-from gym import Env
-import gym.spaces as spaces
-
-class NimEnv(Env):
-    pass
diff --git a/learnrl/environments/single_agents/__init__.py b/learnrl/environments/single_agents/__init__.py
deleted file mode 100644
index 300c310..0000000
--- a/learnrl/environments/single_agents/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-from learnrl.environments.single_agents.random_nim import RdNimEnv
-from learnrl.environments.single_agents.catch import CatchEnv
diff --git a/learnrl/environments/single_agents/catch/__init__.py b/learnrl/environments/single_agents/catch/__init__.py
deleted file mode 100644
index b084416..0000000
--- a/learnrl/environments/single_agents/catch/__init__.py
+++ /dev/null
@@ -1,83 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-from gym import Env, spaces
-import numpy.random as rd
-import numpy as np
-
-class CatchEnv(Env):
-
-    def __init__(self, area_shape=(1, 1), n_apples=10, n_steps=None,
-                       basket_shape=(0.2, 0.05), apple_shape=(0.05, 0.05),
-                       basket_speed=0.05, apple_speed=0.1):
-        self.action_space = spaces.Discrete(3)
-
-        self.max_observation = (
-            area_shape[0] - apple_shape[0],
-            area_shape[1] - apple_shape[1],
-            area_shape[0] - basket_shape[0]
-        )
-        self.observation_space = spaces.Box(low=np.array([0, 0, 0]),
-                                            high=np.array(self.max_observation),
-                                            dtype=np.float64)
-
-        self.area_shape = area_shape
-
-        self.basket_shape = basket_shape
-        self.basket_speed = basket_speed
-
-        self.apple_shape = apple_shape
-        self.apple_speed = apple_speed
-
-        self.n_steps = int(n_apples * (area_shape[1]/apple_speed)) if n_steps is None else n_steps
-        self.steps = 0
-        self.n_apples = n_apples
-        self.apples = 0
-
-        self._get_new_apple()
-        self.basket_pos = self.max_observation[2] / 2
-
-    def step(self, action):
-        self._update_basket_pos(action)
-        reward = self._update_apple_pos()
-        observation = self._get_observation()
-        done = self.steps > self.n_steps or self.apples > self.n_apples
-        self.steps += 1
-        return observation, reward, done, {}
-    
-    def reset(self):
-        self.apples = 0
-        self.steps = 0
-        self._get_new_apple()
-        self.basket_pos = self.max_observation[2] / 2
-        return self._get_observation()
-    
-    def _update_apple_pos(self):
-        self.apple_pos[1] -= self.apple_speed
-        if (self.apple_pos[1] < self.basket_shape[1] and
-            self.apple_pos[0] < self.basket_pos + self.basket_shape[0] and
-            self.apple_pos[0] > self.basket_pos):
-            self._get_new_apple()
-            return 1 / self.n_apples
-        elif self.apple_pos[1] <= 0:
-            self._get_new_apple()
-            return -1 / self.n_apples
-        return 0
-
-    def _update_basket_pos(self, action):
-        if action == 1:
-            self.basket_pos -= self.basket_speed
-        elif action == 2:
-            self.basket_pos += self.basket_speed
-        self.basket_pos = np.clip(self.basket_pos, 0, self.max_observation[2])
-    
-    def _get_observation(self):
-        return np.array(self.apple_pos + [self.basket_pos])
-    
-    def _get_new_apple(self):
-        self.apple_pos = [rd.rand()*self.max_observation[0], self.max_observation[1]]
-        self.apples += 1
-
-    def render(self):
-        pass
-    
diff --git a/learnrl/environments/single_agents/random_crosses_and_noughts/__init__.py b/learnrl/environments/single_agents/random_crosses_and_noughts/__init__.py
deleted file mode 100644
index e87a6be..0000000
--- a/learnrl/environments/single_agents/random_crosses_and_noughts/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-from learnrl.environments.single_agents.random_crosses_and_noughts.game import RdCrossesAndNoughtsEnv
-
diff --git a/learnrl/environments/single_agents/random_crosses_and_noughts/game.py b/learnrl/environments/single_agents/random_crosses_and_noughts/game.py
deleted file mode 100644
index c66b6b1..0000000
--- a/learnrl/environments/single_agents/random_crosses_and_noughts/game.py
+++ /dev/null
@@ -1,285 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-from gym import Env, spaces
-import numpy as np
-from copy import deepcopy
-import pygame
-from pygame.transform import scale
-from itertools import product
-import os
-
-class CrossesAndNoughtsGame():
-
-    def __init__(self):
-        self.grid = np.zeros((3,3), dtype=np.uint8)
-        self.crosses = 1
-        self.nought = 2
-        self.isPygameInit = False
-
-    def is_valid(self, position):
-        position = np.array(position)
-        if position.ndim == 1:
-            position = np.array([position])
-
-        values = []
-        for coord in position:
-            if np.all(np.array(coord) >= 0) and np.all(np.array(coord) < 3):
-                values.append(self.grid[tuple(coord)])
-            else: values.append(0)
-        
-        values = np.array(values, dtype=np.uint8)
-        pos = np.array(position)
-        return np.logical_and(np.logical_and(np.all(pos >= 0, axis=1), np.all(pos < 3, axis=1)), values == 0)
-    
-    def getLegalActions(self):
-        actions = np.array([(0, 0), (0, 1), (0, 2),
-                            (1, 0), (1, 1), (1, 2),
-                            (2, 0), (2, 1), (2, 2)])
-        return actions[self.is_valid(actions)]
-        
-    def play(self, player, position):
-        move = tuple(position)
-        if self.is_valid(move):
-            self.grid[move] = player
-
-    def wincheck(self):
-        x = np.array([1, 1, 1], dtype=np.uint8)
-        o = np.array([2, 2, 2], dtype=np.uint8)
-        
-        # Check all rows
-        for i in range(3):
-            if np.all(self.grid[i] == x): return 1
-            if np.all(self.grid[i] == o): return 2
-
-        # Check all columns
-        for i in range(3):
-            t = [self.grid[j, i] for j in range(3)]
-            if np.all(t == x): return 1
-            if np.all(t == o): return 2
-
-        # Check diagonal
-        t = [self.grid[i, i] for i in range(3)]
-        if np.all(t == x): return 1
-        if np.all(t == o): return 2
-
-        # Check antidiagonal
-        t = [self.grid[2 - i, i] for i in range(3)]
-        if np.all(t == x): return 1
-        if np.all(t == o): return 2
-        
-        # If no one can play
-        if not np.any(self.grid == 0): return 3
-        return 0
-
-    def initPygame(self, scale_factor=5):
-        # pygame.quit()
-        pygame.init() # pylint: disable=E1101 
-                
-        self.scale_factor = scale_factor
-        # Open Pygame window
-        self.window = pygame.display.set_mode((200*scale_factor, 100*scale_factor))
-        # Load images
-        path = os.path.join("envs", "single_agents_envs", "random_crosses_and_noughts")
-        self.empty_grid_img = pygame.image.load(os.path.join(path, "images", "empty_grid.png")).convert()
-        self.empty_grid_img = scale(self.empty_grid_img, (scale_factor*self.empty_grid_img.get_width(), scale_factor*self.empty_grid_img.get_height()))
-
-        self.black_background = pygame.image.load(os.path.join(path, "images", "black_background.png")).convert()
-        self.black_background = scale(self.black_background, (scale_factor*self.black_background.get_width(), scale_factor*self.black_background.get_height()))
-        
-        cross_img = pygame.image.load(os.path.join(path, "images", "cross.png")).convert()
-        cross_img = scale(cross_img, (scale_factor*cross_img.get_width(), scale_factor*cross_img.get_height()))
-
-        nought_img = pygame.image.load(os.path.join(path, "images","nought.png")).convert()
-        nought_img = scale(nought_img, (scale_factor*nought_img.get_width(), scale_factor*nought_img.get_height()))
-
-        self.rect_size = scale_factor*cross_img.get_width()
-
-        self.images = {1:cross_img, 2:nought_img}
-
-        self.states_images = {}
-        # files = os.listdir(os.path.join(path, 'states'))
-        # for filename in files:
-        #     state = int(filename.split('.')[0])
-        #     img = pygame.image.load(os.path.join(path, "states/{}.png".format(state))).convert()
-        #     img = scale(img, (scale_factor*img.get_width(), scale_factor*img.get_height()))
-        #     self.states_images[state] = img
-
-        # Refresh display
-        self.window.blit(self.empty_grid_img, (0,0))
-        pygame.display.flip()
-    
-    def render(self, frame_limit):
-
-        def getPosition(i, j):
-            pos = np.array((34*self.scale_factor*i, 34*self.scale_factor*j), dtype=int)
-            return tuple(pos)
-
-        def getNextState(action, nextActionsState): 
-            for next_state, actions in nextActionsState.items(): 
-                if action in actions: 
-                    return next_state 
-            return "key doesn't exist"
-
-        if not self.isPygameInit:
-            self.initPygame()
-            self.isPygameInit = True
-
-        self.window.fill((0, 0, 0))
-        nextActionsState = self.getNextActionsState(self.grid)
-        states_micro_ids = {next_state:i for (i, next_state) in enumerate(self.getNextActionsState(self.grid))}
-        colors = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255], [64, 64, 64], [192, 192, 192]]
-
-        for i in range(3):
-            for j in range(3):
-                content = self.grid[(i, j)]
-                if content in [1, 2]:
-                    image = self.images[content]
-                    self.window.blit(image, getPosition(i, j))
-                if content == 0:
-                    action = (i, j)
-                    next_state = getNextState(action, nextActionsState)
-                    color = colors[states_micro_ids[next_state]]
-                    position = np.array(getPosition(i, j))
-                    side_size = 32*self.scale_factor
-                    position = tuple(position) + (side_size, side_size)
-                    pygame.draw.rect(self.window, color, position)
-        
-        state = self.getStateHash(self.grid)
-
-        font = pygame.font.Font(None, 12*self.scale_factor)
-        text = font.render(str(state), True, (255, 255, 255))
-        text_pos = (300*self.scale_factor // 2 - text.get_width() // 2, 100*self.scale_factor // 6 - text.get_height() // 2)
-        self.window.blit(self.black_background, (self.scale_factor*100, 0))
-        self.window.blit(text, text_pos)
-
-        pygame.display.flip()
-        if frame_limit != 0:
-            pygame.time.wait(1000//frame_limit)
-        
-    def getStateHash(self, grid):
-
-        def to_state(grid):
-            u = 0
-            for i in range(3):
-                for j in range(3):
-                    u += grid[i][j] * 3**(3*i + j)
-            return u
-
-        def flip(grid):
-            return grid[::-1]
-
-        def rot(grid):
-            flatten = grid[0] + grid[1] + grid[2]
-            newflatten = [flatten[i] for i in [2, 5, 8, 1, 4, 7, 0, 3, 6]]
-            return [newflatten[3*i:3*i+3] for i in range(3)]
-
-        all_states = []
-        tmp = deepcopy(grid.tolist())
-        for _ in range(4):
-            all_states.append(to_state(tmp))
-            tmp = rot(tmp)
-        tmp = flip(tmp)
-        for _ in range(4):
-            all_states.append(to_state(tmp))
-            tmp = rot(tmp)
-        return min(all_states)
-    
-    def getNextActionsState(self, grid):
-
-        next_states = {}
-        
-        nb_X = np.sum(grid==1)
-        nb_O = np.sum(grid==2)
-        if nb_X == nb_O: player = 1
-        else: player = 2
-        
-        legal_actions = [(i, j) for i in range(3) for j in range(3) if grid[i, j] == 0]
-
-        for action in legal_actions:
-            grid_copy = deepcopy(grid)
-            grid_copy[action] = player
-            next_state_id = self.getStateHash(grid_copy)
-            if next_state_id not in next_states:
-                next_states[next_state_id] = [action]
-            else:
-                next_states[next_state_id].append(action)
-        return next_states
-
-    def getObservation(self):
-        return np.array(self.grid)
-
-    def reset(self):
-        self.__init__()
-
-
-class RdCrossesAndNoughtsEnv(Env):
-    
-    def __init__(self, vs_random=False, play_first=True):
-        self.game = CrossesAndNoughtsGame()
-        self.action_space = spaces.MultiDiscrete((3, 3))
-        self.observation_space = spaces.MultiDiscrete(3 * np.ones((3, 3), dtype=np.int64))
-        self.vs_random = vs_random
-        if not play_first:
-            legal_actions = self.game.getLegalActions()
-            if len(legal_actions) > 0:
-                rd_action = legal_actions[np.random.choice(range(len(legal_actions)))]
-                self.game.play(1, rd_action)
-
-    def step(self, action):
-
-        def checkStep(player, other_player):
-            if not pass_turn:
-                reward, done = 0, False
-                winner = self.game.wincheck()
-                if winner == player:
-                    reward = 1
-                    done = True
-                elif winner == other_player:
-                    reward = -1
-                    done = True
-                elif winner == 3:
-                    done = True
-            else:
-                reward, done = -1, False
-            return reward, done
-
-        pass_turn = False
-        observation = self.game.getObservation()
-        nb_X = np.sum(observation==1)
-        nb_O = np.sum(observation==2)
-        if nb_X == nb_O: player, other_player = 1, 2
-        else: player, other_player = 2, 1
-
-        winner = self.game.wincheck()
-        if winner == 0:
-            if self.game.is_valid(action):
-                self.game.play(player, action)
-            else:
-                # Need play random instead
-                legal_actions = self.game.getLegalActions()
-                if len(legal_actions) > 0:
-                    pass_turn = True
-        
-        reward, done = checkStep(player, other_player)
-        
-        if self.vs_random:
-            if not done and not pass_turn:
-                legal_actions = self.game.getLegalActions()
-                if len(legal_actions) > 0:
-                    rd_action = legal_actions[np.random.choice(range(len(legal_actions)))]
-                    self.game.play(other_player, rd_action)
-                reward, done = checkStep(player, other_player)
-
-        observation = self.game.getObservation()
-
-        return observation, reward, done, {'pass_turn':pass_turn}
-    
-    def render(self, frame_limit=0):
-        self.game.render(frame_limit=frame_limit)
-
-    def reset(self):
-        self.game = CrossesAndNoughtsGame()
-        return self.game.getObservation()
-    
-
diff --git a/learnrl/environments/single_agents/random_crosses_and_noughts/images/black_background.png b/learnrl/environments/single_agents/random_crosses_and_noughts/images/black_background.png
deleted file mode 100644
index 1d1644a..0000000
Binary files a/learnrl/environments/single_agents/random_crosses_and_noughts/images/black_background.png and /dev/null differ
diff --git a/learnrl/environments/single_agents/random_crosses_and_noughts/images/cross.png b/learnrl/environments/single_agents/random_crosses_and_noughts/images/cross.png
deleted file mode 100644
index 94b593a..0000000
Binary files a/learnrl/environments/single_agents/random_crosses_and_noughts/images/cross.png and /dev/null differ
diff --git a/learnrl/environments/single_agents/random_crosses_and_noughts/images/empty_grid.png b/learnrl/environments/single_agents/random_crosses_and_noughts/images/empty_grid.png
deleted file mode 100644
index c0a1af8..0000000
Binary files a/learnrl/environments/single_agents/random_crosses_and_noughts/images/empty_grid.png and /dev/null differ
diff --git a/learnrl/environments/single_agents/random_crosses_and_noughts/images/nought.png b/learnrl/environments/single_agents/random_crosses_and_noughts/images/nought.png
deleted file mode 100644
index f0e669f..0000000
Binary files a/learnrl/environments/single_agents/random_crosses_and_noughts/images/nought.png and /dev/null differ
diff --git a/learnrl/environments/single_agents/random_nim/__init__.py b/learnrl/environments/single_agents/random_nim/__init__.py
deleted file mode 100644
index ec90f7b..0000000
--- a/learnrl/environments/single_agents/random_nim/__init__.py
+++ /dev/null
@@ -1,106 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-from gym import Env, spaces
-import numpy.random as rd
-
-class NimGame():
-    def __init__(self, players=("Player 1", "Player 2"), actions=(1,2,3), nb_sticks=10):
-        self.players = players
-        self.losers = {self.players[0]:False, self.players[1]:False}
-        self.actions = actions
-        self.sticks_left = nb_sticks
-    
-    def is_legal(self, sticks):
-        """ Vérifie que l'action est authorisée """
-        return sticks in self.actions
-
-    def remove(self, player, sticks):
-        """ Enlève les batons aux batons restants si il ne reste plus de batons, vous avez perdu"""
-        if not any(lost for lost in self.losers.values()):
-            if self.sticks_left < 1:
-                player_id = self.players.index(player)
-                opponent_id = 1-player_id
-                opponent = self.players[opponent_id]
-                self.losers[opponent] = 1
-            if not any(lost for lost in self.losers.values()):
-                self.sticks_left -= sticks
-                if self.sticks_left < 1:
-                    self.losers[player] = 1
-
-    def won(self, player):
-        """ Si l'autre joueur a perdu, c'est que vous avez gagné"""
-        return any(lost for lost in self.losers.values()) and self.losers[player]==0
-
-    def get_observation(self):
-        """ Return the game state """
-        return int(max(self.sticks_left, 0))
-    
-    def render(self):
-        print(self.sticks_left)
-
-
-class RdNimEnv(Env):
-
-    def __init__(self, initial_state=20, actions=(1,2,3), is_optimal=False):
-        self.initial_state = initial_state
-        self.players = ("Agent", "Environement")
-        self.is_optimal = is_optimal
-        self.actions = actions
-        self.game = NimGame(players=self.players, actions=self.actions, nb_sticks=self.initial_state)
-        self.action_space = spaces.Discrete(len(self.actions))
-        self.observation_space = spaces.Discrete(initial_state + 1)
-    
-    def legal_actions(self, observation):
-        return self.actions
-
-    def step(self, action):
-        reward = 0
-        done = False
-        pass_turn= False
-
-        # Agent turn
-        sticks_to_remove_by_agent = self.actions[action]
-
-        if self.game.is_legal(sticks_to_remove_by_agent):
-            if self.game.won(self.players[0]):
-                reward = 1
-                done = True
-            else:
-                self.game.remove(self.players[0], sticks_to_remove_by_agent)
-        else:
-            reward = -1
-            pass_turn= True
-
-        # Environement turn
-        def env_policy(state, actions, random=True):
-            if (state - 1)%4==0 or random:
-              choice = rd.choice(actions)
-            else:
-              choice = (state - 1)%4
-            return choice
-        
-        if not pass_turn:
-            sticks_to_remove_by_env = env_policy(self.game.get_observation(), self.actions, not self.is_optimal)
-            if self.game.won(self.players[1]) and not done:
-                reward = -1
-                done = True
-            else:
-                self.game.remove(self.players[1], sticks_to_remove_by_env)
-            if self.game.won(self.players[0]) and not done:
-                reward = 1
-                done = True
-        
-        observation = self.game.get_observation()
-        return observation, reward, done, {}
-
-    def reset(self):
-        self.game = NimGame(players=self.players, actions=self.actions,
-                            nb_sticks=self.initial_state)
-        return self.game.get_observation()
-
-    def render(self, mode='human'):
-        self.game.render()
-
-    def close(self):
-        pass
diff --git a/learnrl/environments/single_agents/random_nim/game.py b/learnrl/environments/single_agents/random_nim/game.py
deleted file mode 100644
index ec90f7b..0000000
--- a/learnrl/environments/single_agents/random_nim/game.py
+++ /dev/null
@@ -1,106 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-from gym import Env, spaces
-import numpy.random as rd
-
-class NimGame():
-    def __init__(self, players=("Player 1", "Player 2"), actions=(1,2,3), nb_sticks=10):
-        self.players = players
-        self.losers = {self.players[0]:False, self.players[1]:False}
-        self.actions = actions
-        self.sticks_left = nb_sticks
-    
-    def is_legal(self, sticks):
-        """ Vérifie que l'action est authorisée """
-        return sticks in self.actions
-
-    def remove(self, player, sticks):
-        """ Enlève les batons aux batons restants si il ne reste plus de batons, vous avez perdu"""
-        if not any(lost for lost in self.losers.values()):
-            if self.sticks_left < 1:
-                player_id = self.players.index(player)
-                opponent_id = 1-player_id
-                opponent = self.players[opponent_id]
-                self.losers[opponent] = 1
-            if not any(lost for lost in self.losers.values()):
-                self.sticks_left -= sticks
-                if self.sticks_left < 1:
-                    self.losers[player] = 1
-
-    def won(self, player):
-        """ Si l'autre joueur a perdu, c'est que vous avez gagné"""
-        return any(lost for lost in self.losers.values()) and self.losers[player]==0
-
-    def get_observation(self):
-        """ Return the game state """
-        return int(max(self.sticks_left, 0))
-    
-    def render(self):
-        print(self.sticks_left)
-
-
-class RdNimEnv(Env):
-
-    def __init__(self, initial_state=20, actions=(1,2,3), is_optimal=False):
-        self.initial_state = initial_state
-        self.players = ("Agent", "Environement")
-        self.is_optimal = is_optimal
-        self.actions = actions
-        self.game = NimGame(players=self.players, actions=self.actions, nb_sticks=self.initial_state)
-        self.action_space = spaces.Discrete(len(self.actions))
-        self.observation_space = spaces.Discrete(initial_state + 1)
-    
-    def legal_actions(self, observation):
-        return self.actions
-
-    def step(self, action):
-        reward = 0
-        done = False
-        pass_turn= False
-
-        # Agent turn
-        sticks_to_remove_by_agent = self.actions[action]
-
-        if self.game.is_legal(sticks_to_remove_by_agent):
-            if self.game.won(self.players[0]):
-                reward = 1
-                done = True
-            else:
-                self.game.remove(self.players[0], sticks_to_remove_by_agent)
-        else:
-            reward = -1
-            pass_turn= True
-
-        # Environement turn
-        def env_policy(state, actions, random=True):
-            if (state - 1)%4==0 or random:
-              choice = rd.choice(actions)
-            else:
-              choice = (state - 1)%4
-            return choice
-        
-        if not pass_turn:
-            sticks_to_remove_by_env = env_policy(self.game.get_observation(), self.actions, not self.is_optimal)
-            if self.game.won(self.players[1]) and not done:
-                reward = -1
-                done = True
-            else:
-                self.game.remove(self.players[1], sticks_to_remove_by_env)
-            if self.game.won(self.players[0]) and not done:
-                reward = 1
-                done = True
-        
-        observation = self.game.get_observation()
-        return observation, reward, done, {}
-
-    def reset(self):
-        self.game = NimGame(players=self.players, actions=self.actions,
-                            nb_sticks=self.initial_state)
-        return self.game.get_observation()
-
-    def render(self, mode='human'):
-        self.game.render()
-
-    def close(self):
-        pass
diff --git a/learnrl/environments/single_agents/single_agent_env.md b/learnrl/environments/single_agents/single_agent_env.md
deleted file mode 100644
index f874b33..0000000
--- a/learnrl/environments/single_agents/single_agent_env.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Use classic Gym Environements for single agent environements
-
-It is adviced to simply use classic Gym environements for single agent environements
-
-Documentation can be found at : http://gym.openai.com/docs/
\ No newline at end of file
diff --git a/learnrl/envs.py b/learnrl/envs.py
index 91166d3..63384a3 100644
--- a/learnrl/envs.py
+++ b/learnrl/envs.py
@@ -1,90 +1,74 @@
 # LearnRL a python library to learn and use reinforcement learning
 # Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
 
+"""Environment types, extentions from the classic gym.Env"""
+
+from abc import abstractmethod
 from gym import Env
 
 class TurnEnv(Env):
 
-    r"""
-    A layer over the gym |gym.Env| class able to handle turn based environements with multiple agents.
-   
+    r"""Turn based multi-agents gym environment.
+
+    A layer over the gym |gym.Env| class able to handle
+    turn based environments with multiple agents.
+
     .. note::
         A :ref:`TurnEnv` must be in a :ref:`Playground` in order to work !
 
-    | The main add in TurnEnv is the method: 
-    |   turn
-
+    The only add in TurnEnv is the method "turn",
     On top of the main API basic methodes (see |gym.Env|):
         * step: take a step of the |gym.Env| given the action of the active player
         * reset: reset the |gym.Env| and returns the first observation
         * render
-        * close 
+        * close
         * seed
 
-    Attributes
-    ----------
-        action_space: |gym.Space|
-            The Space object corresponding to actions  
-        observation_space: |gym.Space|
-            The Space object corresponding to observations  
-        reward_range: :class:`tuple`
-            | A tuple corresponding to the min and max possible rewards.
-            | A default reward range set to [-inf,+inf] already exists. 
-            | Set it if you want a narrower range.
+    Attributes:
+        action_space (|gym.Space|): The Space object corresponding to actions.
+        observation_space (|gym.Space|): The Space object corresponding to observations.
 
     """
 
+    @abstractmethod
     def step(self, action):
-        """Perform a step of the environement
-        
-        Parameters
-        ----------
-            action:
-                The action taken by the agent who's turn was given by :meth:`turn`.
-        
-        Return
-        ------
-            observation: 
-                The observation to give to the :class:`~learnrl.agent.Agent`.
-            reward: :class:`float`
-                The reward given to the :class:`~learnrl.agent.Agent` for this step.
-            done: :class:`bool`
-                Is the environement done after this step ?
-            info: :class:`dict`
-                Additional informations given by the |gym.Env|.
-            
+        """Perform a step of the environement.
+
+        Args:
+            action: The action taken by the agent who's turn was given by :meth:`turn`.
+
+        Returns:
+            observation: The observation to give to the :class:`~learnrl.agent.Agent`.
+            reward (float): The reward given to the :class:`~learnrl.agent.Agent` for this step.
+            done (bool): True if the environement is done after this step.
+            info (dict): Additional informations given by the |gym.Env|.
+
         """
-        raise NotImplementedError
+        raise NotImplementedError('TurnEnv.step must be user-defined by subclassing.')
+
+    @abstractmethod
+    def turn(self, state) -> int:
+        """Give the turn to the next agent to play.
 
-    def turn(self, state):
-        """Give the turn to the next agent to play
-    
         Assuming that agents are represented by a list like range(n_player)
         where n_player is the number of players in the game.
-        
-        Parameters
-        ----------
-            state: 
-                | The real state of the environement.
-                | Should be enough to determine which is the next agent to play.
-
-        Return
-        ------
-            agent_id: :class:`int`
-                The next player id
 
-        """
-        raise NotImplementedError
+        Args:
+            state: The state of the environement.
+                Should be enough to determine which is the next agent to play.
 
-    def reset(self):
-        """Reset the environement and returns the initial state
+        Returns:
+            agent_id (int): The next player id
 
-        Return
-        ------
-            observation:
-                The observation for the first :class:`Agent` to play
-        
         """
-        raise NotImplementedError
+        raise NotImplementedError('TurnEnv.turn must be user-defined by subclassing.')
 
+    @abstractmethod
+    def reset(self):
+        """Reset the environement and returns the initial state.
+
+        Returns:
+            observation: The observation for the first :class:`Agent` to play
 
+        """
+        raise NotImplementedError('TurnEnv.reset must be user-defined by subclassing.')
diff --git a/learnrl/estimators/__init__.py b/learnrl/estimators/__init__.py
deleted file mode 100644
index 936d8d4..0000000
--- a/learnrl/estimators/__init__.py
+++ /dev/null
@@ -1,15 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-from learnrl.estimators.estimator import Estimator
-from learnrl.estimators.numpy import TableEstimator
-
-import importlib
-tensorflow_spec = importlib.util.find_spec('tensorflow')
-
-if tensorflow_spec is not None:
-    from learnrl.estimators.tensorflow import KerasEstimator
-else:
-    class KerasEstimator():
-        def __init__(self, observation_space, action_space, **kwars):
-            raise ImportError('Missing dependency : tensorflow >= 2.0.0')
diff --git a/learnrl/estimators/estimator.py b/learnrl/estimators/estimator.py
deleted file mode 100644
index 67b45bd..0000000
--- a/learnrl/estimators/estimator.py
+++ /dev/null
@@ -1,202 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-import numpy as np
-from copy import deepcopy
-import gym.spaces as spaces
-
-class Estimator():
-
-    """ Estimator base object
-
-    The methods must be specified:
-     - `build(self, **kwargs) -> None`
-     - `fit(self, observations, actions, Y) -> None`
-     - `predict(self, observations, actions) -> Y`
-      
-    Kwargs are passed to the build method.
-
-    Arguments
-    ---------
-        observation_space: |gym.Space|
-            The observation space of the agent.
-        action_space: |gym.Space|
-            The action space of the agent.
-        learning_rate: float
-            The learning rate of the estimator.
-        learning_rate_decay: float
-            The learning rate decay of the estimator.
-        verbose: int
-            The amount of informations to be logged by the agent, 0 is silent.
-
-    Attributes
-    ----------
-        name: str
-            The name of the Estimator
-        observation_space: |gym.Space|
-            The observation space of the agent
-        observation_size: int
-            The size of the observation space
-        observation_shape: tuple
-            The shape of the observation space
-        observation_encoder: func
-            The encoder of the observation space (identity by default)
-        observation_decoder: func
-            The decoder of the observation space (identity by default)
-        action_space: |gym.Space|
-            The action space of the agent
-        action_size: int
-            The size of the action space
-        action_shape: tuple
-            The shape of the action space
-        action_encoder: func
-            The encoder of the action space (identity by default)
-        action_decoder: func
-            The decoder of the action space (identity by default)
-    
-
-    """
-
-    def __init__(self, observation_space, action_space, learning_rate=0.1, learning_rate_decay=0, step_skip=0, verbose=0,**kwargs):
-        self.observation_space = observation_space
-        self.action_space = action_space
-
-        self.observation_size = self._get_space_size(observation_space)
-        self.observation_shape = self._get_space_shape(observation_space)
-
-        self.action_size = self._get_space_size(action_space)
-        self.action_shape = self._get_space_shape(action_space)
-
-        self.observation_encoder, self.observation_decoder = lambda x: x, lambda x: x
-        self.action_encoder, self.action_decoder = lambda x: x, lambda x: x
-
-        self.learning_rate = learning_rate
-        self.learning_rate_decay = learning_rate_decay
-        self.verbose = verbose
-
-        self.name = None
-        self.build(**kwargs)
-    
-    def build(self, **kwargs):
-        """ Build the estimator """
-        raise NotImplementedError
-
-    def _fit(self, observations, actions, Y):
-        if observations is None or actions is None or Y is None :
-            return
-        return self.fit(observations, actions, Y)
-    
-    def fit(self, observations, actions, Y):
-        """ Fit the estimator
-        
-        Arguments
-        ---------
-            observations: np.ndarray
-                The sample of observations.
-            actions: np.ndarray
-                The sample of actions.
-            Y:
-                The ground truth that the estimator should predict.
-        
-        """
-        raise NotImplementedError
-    
-    def predict(self, observations, actions):
-        """ Uses the estimator to make a prediction
-
-        Arguments
-        ---------
-            observations: np.ndarray
-                The sample of observations.
-            actions: np.ndarray
-                The sample of actions.       
-        
-        """
-        raise NotImplementedError
-    
-    def encoder(self, arr, arr_type):
-        flatten_array = np.reshape(arr, (arr.shape[0], -1))
-        if arr_type == 'action':
-            if self.action_encoder:
-                return np.apply_along_axis(self.action_encoder, axis=1, arr=flatten_array)
-            else:
-                return arr
-        elif arr_type == 'observation':
-            if self.observation_encoder:
-                return np.apply_along_axis(self.observation_encoder, axis=1, arr=flatten_array)
-            else:
-                return arr
-        else:
-            raise ValueError(f"{arr_type} is an unkwoned type of array, use 'action' or 'observation' instead")
-
-    def update_learning_rate(self, learning_rate=None):
-        if learning_rate is not None:
-            self.learning_rate = learning_rate
-        else:
-            self.learning_rate *= 1 - self.learning_rate_decay
-
-    @staticmethod
-    def _get_space_size(space):
-        if isinstance(space, spaces.Discrete):
-            return space.n
-        elif isinstance(space, spaces.MultiDiscrete):
-            return np.prod(space.nvec)
-        elif isinstance(space, spaces.Box):
-            return np.prod(space.shape)
-        else:
-            raise TypeError(f'Space {type(space)} is not supported yet ... open an issue if needed')
-
-    @staticmethod
-    def _get_space_shape(space):
-        if isinstance(space, spaces.Discrete):
-            return (space.n,)
-        elif isinstance(space, spaces.MultiDiscrete):
-            return space.nvec.shape
-        elif isinstance(space, spaces.Box):
-            return space.shape
-        else:
-            raise TypeError(f'Space {type(space)} is not supported yet ... open an issue if needed')
-
-    @staticmethod
-    def _get_table_encoder_decoder(space):
-        int_id = lambda x: int(x)
-        
-        if isinstance(space, spaces.Discrete):
-            return int_id, int_id
-        
-        elif isinstance(space, spaces.MultiDiscrete):
-            base_mat = np.ones_like(space.nvec, dtype=np.uint32)
-            rank = base_mat.ndim
-            p = 1
-            if rank == 0:
-                return int_id, int_id
-            elif rank == 1:
-                for i in range(len(base_mat)):
-                    base_mat[i] = p
-                    p *= space.nvec[i]
-            elif rank == 2:
-                for i in range(base_mat.shape[0]): # pylint:disable=E1136
-                    for j in range(base_mat.shape[1]): # pylint:disable=E1136
-                        base_mat[i, j] = p
-                        p *= int(space.nvec[i, j])
-            else:
-                raise ValueError(f'Arrays of rank {rank} are not supported yet ... open an issue if needed')
-            
-            def hash_multidiscrete(space_sample, base_mat=base_mat):
-                return np.sum(space_sample.flatten()*base_mat.flatten())
-            
-            def invert_hash_multidiscrete(hashed_space_sample, base_mat=base_mat):
-                flat_mat = base_mat.flatten()
-                space_sample = np.zeros_like(flat_mat)
-                for i in range(len(space_sample))[::-1]:
-                    space_sample[i] = hashed_space_sample // flat_mat[i]
-                    hashed_space_sample -= space_sample[i] * flat_mat[i]
-                return space_sample.reshape(base_mat.shape)
-
-            return hash_multidiscrete, invert_hash_multidiscrete
-        else:
-            raise TypeError(f'Cannot handle the space of type {type(space)}')
-
-    def __call__(self, observations, actions=None):
-        return self.predict(observations, actions)
-
diff --git a/learnrl/estimators/numpy.py b/learnrl/estimators/numpy.py
deleted file mode 100644
index e7d0b18..0000000
--- a/learnrl/estimators/numpy.py
+++ /dev/null
@@ -1,33 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-import numpy as np
-from learnrl.estimators import Estimator
-
-class TableEstimator(Estimator):
-
-    def build(self, **kwargs):
-        dtype = kwargs.get('dtype', None)
-        self.name = 'table'
-        self.observation_encoder, self.observation_decoder = self._get_table_encoder_decoder(self.observation_space)
-        self.action_encoder, self.action_decoder = self._get_table_encoder_decoder(self.action_space)
-        self.table = np.zeros((self.observation_size, self.action_size), dtype=dtype)
-    
-    def load(self, data):
-        self.table = data
-
-    def fit(self, observations, actions, Y):
-        delta = Y - self.table[observations, actions]
-        self.table[observations, actions] += self.learning_rate * delta
-
-        return {'loss': np.mean(delta**2)}
-    
-    def predict(self, observations, actions):
-        if actions is not None:
-            return self.table[observations, actions]
-        else:
-            return self.table[observations, :]
-        
-    def __str__(self):
-        return str(self.table)
-
diff --git a/learnrl/estimators/tensorflow.py b/learnrl/estimators/tensorflow.py
deleted file mode 100644
index 2801fd5..0000000
--- a/learnrl/estimators/tensorflow.py
+++ /dev/null
@@ -1,80 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-from learnrl.estimators import Estimator
-
-import numpy as np
-from tensorflow.keras.models import Model, clone_model
-import tensorflow as tf
-
-class KerasEstimator(Estimator):
-
-    def __init__(self, observation_space, action_space,
-                       learning_rate=1e-3, learning_rate_decay=0,
-                       batch_size=32, freezed_steps=0, **kwargs):
-        self.model = Model()
-        super().__init__(observation_space, action_space, learning_rate, learning_rate_decay, **kwargs)
-        self.action_encoder, self.action_decoder = self._get_table_encoder_decoder(self.action_space)
-        self.freezed_steps = freezed_steps
-        if self.freezed_steps > 0:
-            self.step_freezed_left = freezed_steps
-            self.model_freezed = clone_model(self.model)
-        self.name = 'keras'
-        self.batch_size = batch_size
-
-    def build(self, **kwargs):
-        raise NotImplementedError
-
-    def preprocess(self, observations:tf.Tensor, actions:tf.Tensor=None):
-        raise NotImplementedError
-
-    def fit(self, observations, actions, Y):
-        logs = {}
-
-        observations_tensor = tf.convert_to_tensor(observations)
-        actions_tensor = tf.convert_to_tensor(actions)
-        Y_tensor = tf.convert_to_tensor(Y, dtype=tf.float32)
-
-        metrics = self.train_on_experience_batch(observations_tensor, actions_tensor, Y_tensor)
-        metrics = {key:metrics[key].numpy() for key in metrics}
-        logs.update(metrics)
-
-        if self.freezed_steps > 0:
-            logs.update({'freezed_update': self.step_freezed_left == 0})
-            if self.step_freezed_left == 0:
-                self.model_freezed.set_weights(self.model.get_weights())
-                self.step_freezed_left = self.freezed_steps
-                if self.verbose > 0:
-                    print("Freezed model updated")
-            self.step_freezed_left -= 1
-
-        return logs
-
-    # @tf.function(experimental_relax_shapes=True)
-    def train_on_experience_batch(self, observations:tf.Tensor, actions:tf.Tensor, Y:tf.Tensor):
-        x_train = self.preprocess(observations, actions)
-        y_pred = self.model.predict_step(x_train)
-
-        actions_indices = tf.stack((tf.range(len(actions), dtype=actions.dtype), actions), axis=-1)
-        y_action_pred = tf.gather_nd(y_pred, actions_indices)
-        y_train = tf.tensor_scatter_nd_add(y_pred, actions_indices, tf.add(Y, -y_action_pred))
-
-        metrics = self.model.train_step((x_train, y_train))
-        return metrics
-
-    def predict(self, observations, actions=None):
-        x = self.preprocess(observations, actions)
-        x = tf.convert_to_tensor(x)
-        Y = self.predict_on_experience_batch(x)
-        if actions is not None:
-            return Y[actions]
-        else:
-            return Y
-
-    # @tf.function(experimental_relax_shapes=True)
-    def predict_on_experience_batch(self, x):
-        if self.freezed_steps > 0:
-            Y = self.model_freezed.predict_step(x)
-        else:
-            Y = self.model.predict_step(x)
-        return Y
diff --git a/learnrl/evaluation.py b/learnrl/evaluation.py
deleted file mode 100644
index 4b658d8..0000000
--- a/learnrl/evaluation.py
+++ /dev/null
@@ -1,138 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-"""
-Evaluation methodes to modify the value functions from experiences
-"""
-from learnrl.memory import Memory
-from learnrl.control import Control
-from learnrl.estimators import Estimator
-
-import numpy as np
-from copy import copy
-from itertools import cycle
-
-class Evaluation():
-
-    """ Evaluation base object
-    
-    The method eval must be specified.
-    
-    Example
-    -------
-
-    >>> from learnrl.evaluation import Evaluation
-    ...
-    ... class MyEvaluation(Evaluation):
-    ... 
-    ...     ''' Description '''
-    ...
-    ...     def __init__(self, **kwargs):
-    ...         super().__init__(name="my_evaluation_name", **kwargs)
-    ...
-    ...     def eval(self, reward, done, next_observation, action_values:Estimator, action_visits:Estimator, control:Control):
-    ...         ...
-    ...         ...
-    ...         return expected_returns
-    
-    Parameters
-    ----------
-        name: :class:`str`
-            Name of the evaluation.
-        discount: :class:`float` (default is .999)
-           Discount factor.
-           
-    Attributes
-    ----------
-        All args becomes attributes.
-    """
-
-    def __init__(self, name=None, discount=.999, **kwargs):
-        if name is None:
-            raise ValueError("The Evaluation object must have a name")
-        self.name = name
-        self.discount = discount
-
-    def eval(self, reward, done, next_observation, action_values:Estimator, action_visits:Estimator, control:Control):
-        """ Evaluate the expected futur rewards
-        
-        Arguments
-        ---------
-            reward: float
-                The real reward of the last step
-            done: bool
-                True if the environment has ended and previous step was the last.
-            next_observation: np.ndarray
-                The observation made after the step, used to predict what will happen next.
-            action_values: :class:`~learnrl.estimators.Estimator`
-                The action_values approximated by the agent.
-            action_visits: :class:`~learnrl.estimators.Estimator`
-                The action_visits approximated by the agent.
-            control: :class:`~learnrl.control.Control`
-                The control object used to predict agent behavior,
-                can be the same (on-policy) or another (off-policy) that the agent is using.
-        
-        Return
-        ------
-            expected_return: np.ndarray
-                The batch of expected returns (some of rewards) at the end of the episode for each experience.
-
-        """
-        raise NotImplementedError
-
-    def _get_evaluation(self, reward, done, next_observation, action_values:Estimator, action_visits:Estimator, target_control:Control):
-        expected_return = self.eval(reward, done, next_observation, action_values, action_visits, target_control)
-        return expected_return
-
-    def __str__(self):
-        return self.name
-
-
-class MonteCarlo(Evaluation):
-
-    """ MonteCarlo methods uses experimental mean to approximate theorical mean. """
-
-    def __init__(self, **kwargs):
-        super().__init__(name="montecarlo", **kwargs)
-
-    def eval(self, reward, done, next_observation, action_values:Estimator, action_visits:Estimator, control:Control):
-        total_return = np.sum(reward) * np.ones_like(reward)
-        return total_return
-
-class TemporalDifference(Evaluation):
-
-    """ TemporalDifference uses previously computed action_values to approximate the expected return at each step. """
-
-    def __init__(self, **kwargs):
-        super().__init__(name="tempdiff", **kwargs)
-
-    def eval(self, reward, done, next_observation, action_values:Estimator, action_visits:Estimator, control:Control):
-        expected_futur_reward = reward.astype(np.float64)
-        not_done = np.logical_not(done)
-
-        if len(next_observation[not_done]) > 0:
-            policy = control._get_policy
-            action_impact = policy(next_observation[not_done], action_values, action_visits)
-            expected_futur_reward[not_done] += self.discount * np.sum(action_impact * action_values(next_observation[not_done]), axis=-1)
-
-        return expected_futur_reward
-
-class QLearning(Evaluation):
-
-    """
-    QLearning is just TemporalDifference with Greedy target_control.
-
-    This object is just optimized for computation speed. """
-
-    def __init__(self, **kwargs):
-        super().__init__(name="qlearning", **kwargs)
-
-    def eval(self, reward, done, next_observation, action_values:Estimator, action_visits:Estimator, control:Control):
-        expected_futur_reward = reward.astype(np.float64)
-        not_done = np.logical_not(done)
-
-        if len(next_observation[not_done]) > 0:
-            expected_futur_reward[not_done] += self.discount * np.amax(action_values(next_observation[not_done]), axis=-1)
-        
-        return expected_futur_reward
-
diff --git a/learnrl/memory.py b/learnrl/memory.py
deleted file mode 100644
index 0026f63..0000000
--- a/learnrl/memory.py
+++ /dev/null
@@ -1,117 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-import numpy as np
-from copy import copy
-import collections.abc as collections
-
-class Memory():
-
-    """
-    A general memory for reinforcement learning agents
-
-    Using the methods :meth:`remember` and :meth:`forget`
-    any :class:`~learnrl.agent.Agent` have a standardized :class:`~learnrl.memory.Memory` !
-    
-    Attributes
-    ----------
-        max_memory_len: :class:`int`
-            Max number of experiences stocked by the :class:`~learnrl.memory.Memory`
-        datas: :class:`dict`
-            The dictionary of experiences as :class:`numpy.ndarray`
-        MEMORY_KEYS:
-            | The keys of core parameters to gather from experience
-            | ('observation', 'action', 'reward', 'done', 'next_observation', 'info')
-    """
-
-    def __init__(self, max_memory_len=10000): 
-        self.MEMORY_KEYS = ('observation', 'action', 'reward', 'done', 'next_observation', 'info')
-        self.datas = {key:None for key in self.MEMORY_KEYS}
-        self.max_memory_len = max_memory_len
-
-    def remember(self, observation, action, reward, done, next_observation=None, info={}, **param):
-        """ Add the new experience into the memory forgetting long past experience if neccesary
-        
-        Parameters
-        ----------
-            observation:
-                The observation given by the |gym.Env| or transformed by an :class:`~learnrl.agent.Agent` hash function
-            action:
-                The action given by to |gym.Env| or transformed by an :class:`~learnrl.agent.Agent` hash function
-            reward: :class:`float`
-                The reward given by the |gym.Env|
-            done: :class:`bool`
-                Whether the |gym.Env| had ended after the action
-            next_observation:
-                The next_observation given by the |gym.Env| or transformed by the :class:`~learnrl.agent.Agent` hash function
-            info: :class:`dict`
-                Additional informations given by the |gym.Env|
-            **kwargs:
-                Optional additional stored informations
-        
-        """
-
-        def _remember_key(datas, key, value, max_memory_len=self.max_memory_len):
-            if datas[key] is None:
-                datas[key] = value
-            else:
-                if len(datas[key]) < max_memory_len:
-                    datas[key] = np.concatenate((datas[key], value), axis=0)
-                else:
-                    datas[key] = np.roll(datas[key], shift=-1, axis=0)
-                    datas[key][-1] = np.array(value)
-
-        for key, value in zip(self.MEMORY_KEYS, (observation, action, reward, done, next_observation, info)):
-            # Check that value is an instance of numpy.ndarray or transform the value
-            if type(value) == np.ndarray:
-                value = value[np.newaxis, ...]
-            if isinstance(value, collections.Sequence) or type(value) != np.ndarray:
-                value = np.array([value])
-            _remember_key(self.datas, key, value)
-        
-        # Add optional supplementary parameters
-        for key in param:
-            _remember_key(self.datas, key, param[key])
-
-
-    def sample(self, sample_size=0, method='naive_uniform', return_copy=True):
-        """ Return a sample of experiences stored in the memory
-        
-        Parameters
-        ----------
-            sample_size: int
-                The size of the sample to get from memory, if 0 return all memory.
-            method: str
-                On of ("last", "naive_uniform", "uniform"). The sampling method.
-            copy: bool
-                If True, return a copy of the memory sampled.
-        
-        Return
-        ------
-            datas: list
-                The list of :class:`numpy.ndarray` of memory samples for each key in MEMORY_KEYS.
-        
-        """
-        if method not in ['naive_uniform', 'last']:
-            raise NotImplementedError(f'Method {method} is not implemented yet')
-        
-        n_experiences = len(self.datas['observation'])
-
-        if n_experiences <= sample_size or sample_size == 0:
-            datas = [self.datas[key] for key in self.MEMORY_KEYS]
-        else:
-            if method == 'naive_uniform':
-                sample_indexes = np.random.choice(np.arange(n_experiences), size=sample_size)
-            elif method == 'last':
-                sample_indexes = np.arange(n_experiences - sample_size, n_experiences)
-            datas = [self.datas[key][sample_indexes] for key in self.MEMORY_KEYS]
-
-        if return_copy:
-            datas = [copy(value) for value in datas]
-
-        return datas
-
-    def forget(self):
-        """ Remove all memory"""
-        self.datas = {key:None for key in self.MEMORY_KEYS}
-
diff --git a/learnrl/playground.py b/learnrl/playground.py
index 49d1c60..9836988 100644
--- a/learnrl/playground.py
+++ b/learnrl/playground.py
@@ -1,168 +1,384 @@
 # LearnRL a python library to learn and use reinforcement learning
 # Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
 
-import numpy as np
-from copy import copy
+"""Playground to manage interactions between environment and agent(s)"""
+
 import warnings
 
-from time import time
+from typing import List, Union, Callable, Any
+from abc import abstractmethod
+from numbers import Number
+
+import numpy as np
 from gym import Env
 
 from learnrl import Agent, TurnEnv
-from learnrl.callbacks import CallbackList, Logger
+from learnrl.callbacks import Callback, CallbackList, Logger
+
+
+class DoneHandler():
+
+    """Helper to modify the done given by the environment.
+
+    You need to specify the method:
+     - `done(self, observation, action, reward, done, info, next_observation) -> bool`
+
+    You can also define __init__ and reset() if you want to store anything.
+
+    """
+
+    @abstractmethod
+    def done(self, previous_observation, action, reward,
+            done, info, observation, logs) -> bool:
+        """Replace the environment done.
+
+        Often used to make episodes shorter when the agent is stuck for example.
+
+        Args:
+            previous_observation: Previous observation.
+            action: Current action.
+            reward: Current reward.
+            done: done given by the environment.
+            info: Addition informations given by the environment.
+            observation: Current observation.
+
+        """
+
+    def reset(self):
+        """Reset the DoneHandler
+
+        Called automaticaly in :meth:`Playground.run`.
+        Used only if variables are stored by the DoneHandler.
+
+        """
+
+    def _done(self, *args) -> bool:
+        done = self.done(*args)
+        if not isinstance(done, bool) and \
+           not (isinstance(done, np.ndarray) and done.dtype == bool):
+            raise ValueError(f"Done should be bool, got {done} of type {type(done)} instead")
+        return done
+
+    def __call__(self, *args) -> bool:
+        return self._done(*args)
+
+
+class RewardHandler():
+
+    """Helper to modify the rewards given by the environment.
+
+    You need to specify the method:
+     - `reward(self, observation, action, reward, done, info, next_observation) -> float`
+
+    You can also define __init__ and reset() if you want to store anything.
+
+    """
+
+    @abstractmethod
+    def reward(self, previous_observation, action, reward,
+            done, info, observation, logs)-> float:
+        """Replace the environment reward.
+
+        Often used to scale rewards or to do reward shaping.
+
+        Args:
+            previous_observation: Previous observation.
+            action: Current action.
+            reward: Current reward.
+            done: done given by the environment.
+            info: Addition informations given by the environment.
+            observation: Current observation.
+
+        """
+
+    def reset(self):
+        """Reset the RewardHandler
+
+        Called automaticaly in :meth:`Playground.run`.
+        Useful only if variables are stored by the RewardHandler.
+
+        """
+
+    def _reward(self, *args) -> float:
+        reward = self.reward(*args)
+        if not isinstance(reward, Number) and \
+           not (isinstance(reward, np.ndarray) and np.issubdtype(reward.dtype, np.floating)):
+            raise ValueError(
+                f"Rewards should be a float, got {reward} of type {type(reward)} instead"
+            )
+        return float(reward)
+
+    def __call__(self, *args) -> float:
+        return self._reward(*args)
 
 
 class Playground():
 
-    """ A playground is used to run agent(s) on an environement.
+    """A playground is used to run interactions between an environement and agent(s)
 
-    Attributes
-    ----------
-        env: :class:`TurnEnv` or |gym.Env|
-            Environement in which agents will play
-        agents: list or :class:`Agent`
-            List of :class:`Agent` to run on the :class:`TurnEnv`
+    Attributes:
+        env (gym.Env):  Environement in which the agent(s) will play.
+        agents (list of learnrl.Agent): List of agents to play.
 
     """
 
-    def __init__(self, environement:Env, agents:Agent):
-        assert isinstance(environement, Env)
+    def __init__(self, environement:Env, agents:Union[Agent, List[Agent]], agents_order=None):
+        """A playground is used to run agent(s) on an environement
+
+        Args:
+            env: Environement in which the agent(s) will play.
+            agents: List of agents to play (can be only one agent).
+
+        """
+        if not isinstance(environement, Env):
+            raise TypeError('environement should be a subclass of gym.Env')
         if isinstance(agents, Agent):
             agents = [agents]
         for agent in agents:
-            assert isinstance(agent, Agent)
-        
+            if not isinstance(agent, Agent):
+                raise TypeError('All agents should be a subclass of learnrl.Agent')
+
         self.env = environement
         self.agents = agents
+        self.agents_order = None
+        self.set_agents_order(agents_order)
+
+    @staticmethod
+    def _get_episodes_cycle_len(episodes_cycle_len, episodes):
+        if 0 < episodes_cycle_len < 1:
+            episodes_cycle_len = max(1, int(episodes_cycle_len*episodes))
+
+        episodes_cycle_len = int(episodes_cycle_len)
+        if episodes_cycle_len <= 0:
+            raise ValueError('episodes_cycle_len must be > 0')
+
+        return episodes_cycle_len
+
+    def _build_callbacks(self, callbacks, logger, params):
+        callbacks = callbacks if callbacks is not None else []
+        if logger is not None:
+            callbacks += [logger]
+
+        callbacks = CallbackList(callbacks)
+        callbacks.set_params(params)
+        callbacks.set_playground(self)
+        return callbacks
+
+    def _reset(self, reward_handler, done_handler):
+        previous = [{'observation':None, 'action':None, 'reward':None, 'done':None, 'info':None}
+            for _ in range(len(self.agents))]
+        if isinstance(reward_handler, RewardHandler):
+            reward_handler.reset()
+        if isinstance(done_handler, DoneHandler):
+            done_handler.reset()
+        return self.env.reset(), 0, False, previous
+
+    def _get_next_agent(self, observation):
+        turn_id = self.env.turn(observation) if isinstance(self.env, TurnEnv) else 0
+        try:
+            agent_id = self.agents_order[turn_id]
+            agent = self.agents[agent_id]
+        except IndexError as index_error:
+            error_msg = f'Not enough agents to play environement {self.env}'
+            raise ValueError(error_msg) from index_error
+        return agent, agent_id
+
+    @staticmethod
+    def _call_handlers(reward, done, experience,
+            reward_handler=None, done_handler=None, logs=None):
+        logs = {} if logs is None else logs
+
+        logs.update({'reward': reward})
+        if reward_handler is not None:
+            handled_reward = reward_handler(*experience, logs)
+            experience['reward'] = handled_reward
+            logs.update({'handled_reward': handled_reward})
+
+        logs.update({'done': done})
+        if done_handler is not None:
+            handled_done = done_handler(*experience, logs)
+            experience['done'] = handled_done
+            logs.update({'handled_done': handled_done})
+
+    def _run_step(self,
+            observation: Any,
+            previous: list,
+            logs: dict,
+            learn=False,
+            render=False,
+            render_mode='human',
+            reward_handler=None,
+            done_handler=None,
+        ):
+        """Run a single step"""
+        # Render the environment
+        if render:
+            self.env.render(render_mode)
+
+        agent, agent_id = self._get_next_agent(observation)
+
+        # If the agent has played before, perform a learning step
+        prev = previous[agent_id]
+        if learn and prev['observation'] is not None:
+            agent.remember(
+                prev['observation'], prev['action'], prev['reward'],
+                prev['done'], observation, prev['info']
+            )
+            agent_logs = agent.learn()
+            logs.update({f'agent_{agent_id}': agent_logs})
+
+        # Ask action to agent
+        action = agent.act(observation, greedy=not learn)
+
+        # Perform environment step
+        next_observation, reward, done, info = self.env.step(action)
+
+        # Adds step informations to logs
+        experience = {
+            'observation': observation,
+            'action': action,
+            'reward': reward,
+            'done': done,
+            'next_observation': next_observation,
+            'info': info
+        }
+        logs.update({'agent_id':agent_id})
+        logs.update(experience)
+
+        # Use Handlers
+        self._call_handlers(reward, done, experience, reward_handler, done_handler, logs)
+
+        # Store experience in prev
+        if learn:
+            for key, value in zip(prev, [observation, action, reward, done, info]):
+                prev[key] = value
+
+            # Perform a last learning step if done
+            if done:
+                agent.remember(**experience)
+                agent_logs = agent.learn()
+                logs.update({f'agent_{agent_id}': agent_logs})
+
+        # Do a last rendering if done
+        if done and render:
+            self.env.render(render_mode)
+
+        return next_observation, done
+
+    def run(self,
+            episodes: int,
+            render: bool=True,
+            render_mode: str='human',
+            learn: bool=True,
+            steps_cycle_len: int=10,
+            episodes_cycle_len: Union[int, float]=0.05,
+            verbose: int=0,
+            callbacks: List[Callback]=None,
+            logger: Callback=None,
+            reward_handler: Union[Callable, RewardHandler]=None,
+            done_handler: Union[Callable, DoneHandler]=None,
+            **kwargs
+        ):
+
+        """Let the agent(s) play on the environement for a number of episodes.
+
+        Additional arguments will be passed to the default logger.
+
+        Args:
+            episodes: Number of episodes to run.
+            render: If True, call |gym.render| every step.
+            render_mode: Rendering mode.
+                One of {'human', 'rgb_array', 'ansi'} (see |gym.render|).
+            learn: If True, call :meth:`Agent.learn` every step.
+            steps_cycle_len: Number of steps that compose a cycle.
+            episode_cycle_len: Number of episodes that compose a cycle.
+                If between 0 and 1, this in understood as a proportion.
+            verbose: The verbosity level: 0 (silent), 1 (cycle), 2 (episode),
+                3 (step_cycle), 4 (step), 5 (detailed step).
+            callbacks: List of :class:`~learnrl.callbacks.Callback` to use in runs.
+            reward_handler: A callable to redifine rewards of the environement.
+            done_handler: A callable to redifine the environement end.
+            logger: Logging callback to use.
+                If None use the default :class:`~learnrl.callbacks.logger.Logger`.
 
-    def run(self, episodes, render=True, learn=True, cycle_len=None, cycle_prop=0.05, verbose=0,
-                  callbacks=[], logger=None, reward_handler=None, done_handler=None, titles_on_top=True):
-        
-        """ Let the agent(s) play on the environement for a number of episodes.
-        
-        Parameters
-        ----------
-            episodes: int
-                Number of episodes to run.
-            render: bool
-                If True, call :meth:`TurnEnv.render` every step.
-            learn: bool
-                If True, call :meth:`Agent.learn` every step.
-            cycle_len: int
-                Number of episodes that compose a cycle, used in :class:`~learnrl.callbacks.Callback`.
-            cycle_prop: float
-                Propotion of total episodes to compose a cycle, used if cycle_len is not set.
-            verbose: int
-                The verbosity level: 0 (silent), 1 (cycle), 2 (episode), 3 (step), 4 (detailed step).
-            callbacks: list
-                List of :class:`~learnrl.callbacks.Callback` to use in runs.
-            reward_handler: func or :class:`RewardHandler`
-                A callable to redifine rewards of the environement.
-            done_handler: func or :class:`DoneHandler`
-                A callable to redifine the environement end.
-            titles_on_top: bool
-                Whether to print titles on top, if false display titles in each line. Default True.
-        
         """
-        cycle_len = cycle_len or max(1, int(cycle_prop*episodes))
+        episodes_cycle_len = self._get_episodes_cycle_len(episodes_cycle_len, episodes)
 
         params = {
             'episodes': episodes,
-            'cycle_len': cycle_len,
+            'episodes_cycle_len': episodes_cycle_len,
+            'steps_cycle_len': steps_cycle_len,
             'verbose': verbose,
             'render': render,
             'learn': learn
         }
 
-        logger = logger if logger else Logger(titles_on_top=titles_on_top)
-        callbacks = CallbackList(callbacks + [logger])
-        callbacks.set_params(params)
-        callbacks.set_playground(self)
+        logger = logger if logger else Logger(**kwargs)
+        callbacks = self._build_callbacks(callbacks, logger, params)
 
+        # Start the run
         logs = {}
         logs.update(params)
+
         callbacks.on_run_begin(logs)
 
         for episode in range(episodes):
 
-            if episode % cycle_len == 0:
-                callbacks.on_cycle_begin(episode, logs)
+            if episode % episodes_cycle_len == 0:
+                callbacks.on_episodes_cycle_begin(episode, logs)
 
-            observation = self.env.reset()
-            if isinstance(reward_handler, RewardHandler):
-                reward_handler.reset()
-            if isinstance(done_handler, DoneHandler):
-                done_handler.reset()
+            observation, step, done, previous = self._reset(reward_handler, done_handler)
 
-            previous = [{'observation':None, 'action':None, 'reward':None, 'done':None, 'info':None} for _ in range(len(self.agents))]
-            done = False
-            step = 0
-
-            logs.update({'episode':episode})
+            logs.update({'episode': episode})
             callbacks.on_episode_begin(episode, logs)
 
             while not done:
 
-                if render: self.env.render()
-
-                agent_id = self.env.turn(observation) if isinstance(self.env, TurnEnv) else 0
-                if agent_id >= len(previous):
-                    raise ValueError(f'Not enough agents to play environement {self.env}')
-                agent = self.agents[agent_id]
+                if step % steps_cycle_len == 0:
+                    callbacks.on_steps_cycle_begin(step, logs)
 
-                prev = previous[agent_id]
-                if learn and prev['observation'] is not None:
-                    agent.remember(prev['observation'], prev['action'], prev['reward'], prev['done'], observation, prev['info'])
-                    agent_logs = agent.learn()
-                    logs.update({f'agent_{agent_id}': agent_logs})
-                
-                logs.update({'step':step, 'agent_id':agent_id, 'observation':observation})
+                logs.update({'step': step})
                 callbacks.on_step_begin(step, logs)
 
-                action = agent.act(observation, greedy=not learn)
-                next_observation, reward, done, info = self.env.step(action)
-
-                logs.update({'reward': reward})
-                if reward_handler is not None:
-                    reward = reward_handler(observation, action, reward, done, info, next_observation)
-                    logs.update({'handled_reward': reward})
-                
-                logs.update({'done': done})
-                if done_handler is not None:
-                    done = done_handler(observation, action, reward, done, info, next_observation)
-                    logs.update({'handled_done': done})
-
-                if learn:
-                    for key, value in zip(prev, [observation, action, reward, done, info]):
-                        prev[key] = value
-                    if done:
-                        agent.remember(observation, action, reward, done, next_observation, info)
-                        agent_logs = agent.learn()
-                        logs.update({f'agent_{agent_id}': agent_logs})
-                
-                logs.update({'action': action, 'next_observation': next_observation})
-                logs.update(info)
-
+                observation, done = self._run_step(
+                    observation=observation,
+                    previous=previous,
+                    logs=logs,
+                    learn=learn,
+                    render=render,
+                    render_mode=render_mode,
+                    reward_handler=reward_handler,
+                    done_handler=done_handler,
+                )
                 callbacks.on_step_end(step, logs)
-                
+
+                if (step + 1) % steps_cycle_len == 0 or done:
+                    callbacks.on_steps_cycle_end(step, logs)
+
                 step += 1
-                observation = next_observation
-            
+
             callbacks.on_episode_end(episode, logs)
 
-            if (episode + 1) % cycle_len == 0 or episode == episodes - 1:
-                callbacks.on_cycle_end(episode, logs)
-        
+            if (episode + 1) % episodes_cycle_len == 0 or episode + 1 == episodes:
+                callbacks.on_episodes_cycle_end(episode, logs)
+
         callbacks.on_run_end(logs)
 
+
     def fit(self, episodes, **kwargs):
         """Train the agent(s) on the environement for a number of episodes."""
         learn = kwargs.pop('learn', True)
         render = kwargs.pop('render', False)
         if not learn:
-            warnings.warn("learn should be True in Playground.fit(), otherwise the agents will not improve", UserWarning)
+            warnings.warn(
+                "learn should be True in Playground.fit(), otherwise the agents will not improve",
+                UserWarning
+            )
         if render:
-            warnings.warn("rendering degrades heavily computation speed", RuntimeWarning)
+            warnings.warn(
+                "rendering degrades heavily computation speed",
+                RuntimeWarning
+            )
 
         self.run(episodes, render=render, learn=learn, **kwargs)
 
@@ -172,60 +388,49 @@ def test(self, episodes, **kwargs):
         render = kwargs.pop('render', True)
         verbose = kwargs.pop('verbose', 0)
         if learn:
-            warnings.warn("learn should be False in Playground.test(), otherwise the agents will not act greedy and can have random behavior", UserWarning)
+            warnings.warn(
+                "learn should be False in Playground.test(),"
+                "otherwise the agents will not act greedy and can have random behavior",
+                UserWarning
+            )
         if not render and verbose == 0:
-            warnings.warn("you should set verbose > 0 or render=True to have any feedback ...", UserWarning)
+            warnings.warn(
+                "you should set verbose > 0 or render=True to have any feedback ...",
+                UserWarning
+            )
         self.run(episodes, render=render, learn=learn, verbose=verbose, **kwargs)
-    
-
-class DoneHandler():
-
-    """ Helper to modify the done given by the environmen
-
-    You need to specify the method:
-     - `done(self, observation, action, reward, done, info, next_observation) -> done`
-    
-    You can also define __init__ and reset() if you want to store anything.
-
-    """
-
-    def done(self, observation, action, reward, done, info, next_observation):
-        raise NotImplementedError
-
-    def reset(self):
-        pass
 
-    def _done(self, *args):
-        done = self.done(*args)
-        if not isinstance(done, (bool, np.bool, np.bool_)):
-            raise ValueError(f"Done should be bool, got {done} of type {type(done)} instead")
-        return done
-    
-    def __call__(self, *args):
-        return self._done(*args)
+    def set_agents_order(self, agents_order: list) -> list:
+        """Change the agents_order.
 
-class RewardHandler():
+        This will update the agents order.
 
-    """ Helper to modify the rewards given by the environment
-    
-    You need to specify the method:
-     - `reward(self, observation, action, reward, done, info, next_observation) -> reward`
-    
-    You can also define __init__ and reset() if you want to store anything.
-    
-    """
-
-    def reward(self, observation, action, reward, done, info, next_observation):
-        raise NotImplementedError
+        Args:
+            agents_order: New agents indices order.
+                Default is range(n_agents).
 
-    def reset(self):
-        pass
+        Returns:
+            The updated agents ordered indices list.
 
-    def _reward(self, *args):
-        reward = self.reward(*args)
-        if not isinstance(reward, (int, float)):
-            raise ValueError(f"Rewards should be scalars, got {reward} of type {type(reward)} instead")
-        return reward
-    
-    def __call__(self, *args):
-        return self._reward(*args)
+        """
+        if agents_order is None:
+            self.agents_order = list(range(len(self.agents)))
+        else:
+            if len(agents_order) != len(self.agents):
+                raise ValueError(
+                    f"Not every agents have an order number.\n"
+                    f"Custom order: {agents_order} for {len(self.agents)} agents\n"
+                )
+
+            valid_order = True
+            for place in range(len(self.agents)):
+                if not place in agents_order:
+                    valid_order = False
+
+            if not valid_order:
+                raise ValueError(
+                    f"Custom order is not taking every index in [0, n_agents-1].\n"
+                    f"Custom order: {agents_order}"
+                )
+            self.agents_order = agents_order
+        return self.agents_order
diff --git a/pypi-readme.rst b/pypi-readme.rst
new file mode 100644
index 0000000..8d2b6ea
--- /dev/null
+++ b/pypi-readme.rst
@@ -0,0 +1,135 @@
+Welcome to LearnRL's community !
+================================
+
+.. image:: https://img.shields.io/pypi/l/learnrl
+   :alt: PyPI - License
+   :target: https://www.gnu.org/licenses/
+
+
+.. image:: https://app.codacy.com/project/badge/Grade/b4c3818135484e8b9acae67b01526957
+   :alt: Codacy - Quality
+   :target: https://www.codacy.com/gh/MathisFederico/LearnRL/dashboard?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=MathisFederico/LearnRL&amp;utm_campaign=Badge_Grade
+
+
+.. image:: https://app.codacy.com/project/badge/Coverage/b4c3818135484e8b9acae67b01526957
+   :alt: Codacy - Coverage
+   :target: https://www.codacy.com/gh/MathisFederico/LearnRL/dashboard?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=MathisFederico/LearnRL&amp;utm_campaign=Badge_Coverage
+
+
+LearnRL is a library to use and learn reinforcement learning.
+It's also a community off supportive enthousiasts loving to share and build RL-based AI projects !
+We would love to help you make projects with LearnRL, so join us `on Discord <https://discord.gg/z9dd4s5>`_ !
+
+
+About LearnRL
+-------------
+
+LearnRL is a tool to monitor and log reinforcement learning experiments.
+You build/find any compatible agent (only need an act method), you build/find a gym environment, and learnrl will make them interact together !
+LearnRL also contains both tensorboard and weights&biases integrations for a beautiful and sharable experiment tracking !  
+Also, LearnRL is cross platform compatible ! That's why no agents are built-in learnrl itself, but you can check:
+- `LearnRL for Tensorflow <https://github.com/MathisFederico/LearnRL-Tensorflow>`_
+- `LearnRL for Pytorch <https://github.com/MathisFederico/LearnRL-Pytorch>`_
+
+You can build and run your own Agent in a clear and sharable manner !
+
+.. code-block:: python
+
+   import learnrl as rl
+   import gym
+
+   class MyAgent(rl.Agent):
+
+      def act(self, observation, greedy=False):
+         """ How the Agent act given an observation """
+         ...
+         return action
+
+      def learn(self):
+         """ How the Agent learns from his experiences """
+         ...
+         return logs
+
+      def remember(self, observation, action, reward, done, next_observation=None, info={}, **param):
+         """ How the Agent will remember experiences """
+         ...
+
+   env = gym.make('FrozenLake-v0', is_slippery=True) # This could be any gym Environment !
+   agent = MyAgent(env.observation_space, env.action_space)
+
+   playground = rl.Playground(env, agent)
+   playground.fit(2000, verbose=1)
+
+Note that 'learn' and 'remember' are optional, so this framework can also be used for baselines !
+
+You can logs any custom metrics that your Agent/Env gives you and even chose how to aggregate them through different timescales.
+See the `metric codes <https://learnrl.readthedocs.io/en/latest/callbacks.html#metric-codes>`_ for more details.
+
+.. code-block:: python
+
+   metrics=[
+        ('reward~env-rwd', {'steps': 'sum', 'episode': 'sum'}),
+        ('handled_reward~reward', {'steps': 'sum', 'episode': 'sum'}),
+        'value_loss~vloss',
+        'actor_loss~aloss',
+        'exploration~exp'
+    ]
+
+    playground.fit(2000, verbose=1, metrics=metrics)
+
+
+The Playground also allows you to add Callbacks with ease, for example the WandbCallback to have a nice experiment tracking dashboard !
+
+Installation
+------------
+
+Install LearnRL by running::
+
+   pip install learnrl
+
+
+Get started
+-----------
+
+Create:
+- TODO: Numpy tutorials
+- TODO: Tensorflow tutorials
+- TODO: Pytorch tutorials
+
+
+Visualize:
+- TODO: Tensorboard visualisation tutorial
+- TODO: Wandb visualisation tutorial
+- TODO: Wandb sweeps tutorial
+
+
+Documentation
+-------------
+
+See the `latest complete documentation <https://learnrl.readthedocs.io/en/latest/>`_ for more details.
+See the `development documentation <https://learnrl.readthedocs.io/en/dev/>`_ to see what's coming !
+
+
+Contribute
+----------
+
+- `Issue Tracker <https://github.com/MathisFederico/LearnRL/issues>`_.
+- `Projects <https://github.com/MathisFederico/LearnRL/projects>`_.
+
+
+Support
+-------
+
+If you are having issues, please contact us `on Discord <https://discord.gg/z9dd4s5>`_.
+
+
+License
+-------
+
+| The project is licensed under the GNU LGPLv3 license.
+| See LICENCE, COPYING and COPYING.LESSER for more details.
+
+
+.. |gym.Env| replace:: `environment <http://gym.openai.com/docs/#environments>`__
+.. |gym.Space| replace:: `space <http://gym.openai.com/docs/#spaces>`__
+.. |hash| replace:: `perfect hash functions <https://en.wikipedia.org/wiki/Perfect_hash_function>`__
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 074144e..2fa0ff0 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,4 +1,8 @@
 pytest
 pytest-mock
-tensorflow >= 2.1.0
+pytest-check
+pytest-pspec
+pylint
+pylint-pytest
 wandb >= 0.10.0
+tensorflow >= 2.3.0
diff --git a/requirements.txt b/requirements.txt
index ceaf033..fa96cf7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,2 @@
 numpy
-gym
+gym
\ No newline at end of file
diff --git a/setup.py b/setup.py
index ceef5ae..3703222 100644
--- a/setup.py
+++ b/setup.py
@@ -8,13 +8,18 @@
 HERE = pathlib.Path(__file__).parent
 
 # The text of the README file
-README = (HERE / "README.rst").read_text()
+README = (HERE / "pypi-readme.rst").read_text()
 
 def get_version():
     version_file = open('VERSION')
     return version_file.read().strip()
 VERSION = get_version()
 
+def get_requirements():
+    requirements_file = open('requirements.txt')
+    return requirements_file.readlines()
+REQUIREMENTS = get_requirements()
+
 setup(
     name="learnrl",
     version=VERSION,
@@ -26,10 +31,7 @@ def get_version():
     url="https://github.com/MathisFederico/LearnRL",
     packages=find_packages(exclude=("tests", "docs")),
     include_package_data=True,
-    install_requires=[
-        'numpy',
-        'gym',
-    ],
+    install_requires=REQUIREMENTS,
     classifiers=[
         "Programming Language :: Python :: 3",
         "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",


Test patch:
diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/python-tests.yml
similarity index 62%
rename from .github/workflows/pythonpackage.yml
rename to .github/workflows/python-tests.yml
index b2903c9..db3c6a0 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/python-tests.yml
@@ -1,7 +1,7 @@
 # This workflow will install Python dependencies, run tests and lint with a variety of Python versions
 # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
 
-name: Python package
+name: Python tests
 
 on:
   push:
@@ -9,12 +9,12 @@ on:
       - master
       - release/*
   pull_request:
-    branches: [ master, dev ]
+    branches: [ master, dev, release/* ]
 
 jobs:
   build:
 
-    runs-on: windows-latest
+    runs-on: ubuntu-latest
     strategy:
       matrix:
         python-version: [3.6, 3.7, 3.8]
@@ -30,15 +30,11 @@ jobs:
         python -m pip install --upgrade pip
         pip install tensorflow
         pip install -r requirements.txt
-    - name: Lint with flake8
+        pip install -r requirements-dev.txt
+    - name: Lint with pylint
       run: |
-        pip install flake8
-        # stop the build if there are Python syntax errors or undefined names
-        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
-        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
-        flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+        pylint learnrl/
+        pylint --exit-zero tests/
     - name: Test with pytest
       run: |
-        pip install pytest
-        pip install -r requirements-dev.txt
-        pytest --runslow
+        pytest
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..451a5c9
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,5 @@
+[pytest]
+filterwarnings =
+    ignore::UserWarning:gym
+    ignore::DeprecationWarning
+addopts = --pspec 
diff --git a/tests/__init__.py b/tests/__init__.py
index 8b8e4dd..8ce7997 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,2 +1,2 @@
 # LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
\ No newline at end of file
+# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
diff --git a/tests/conftest.py b/tests/conftest.py
index 2f531cc..4b2bc08 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,52 +1,38 @@
+""" Configure tests and define markers """
+
 # LearnRL a python library to learn and use reinforcement learning
 # Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
+# pylint: disable=unused-argument
 
-import pytest
 import importlib
+import pytest
 import learnrl
 
-def pytest_addoption(parser):
-    parser.addoption(
-        "--runslow", action="store_true", default=False, help="run slow tests"
-    )
-
-
-def pytest_configure(config):
-    config.addinivalue_line("markers", "slow: mark test as slow to run")
-
-
-def pytest_collection_modifyitems(config, items):
-    if config.getoption("--runslow"):
-        # --runslow given in cli: do not skip slow tests
-        return
-    skip_slow = pytest.mark.skip(reason="need --runslow option to run")
-    for item in items:
-        if "slow" in item.keywords:
-            item.add_marker(skip_slow)
-
 @pytest.fixture
 def reload_tensorflow():
+    """ Reload tensorflow after hiding it from the modules list """
+
     yield
     importlib.reload(learnrl.callbacks)
-    importlib.reload(learnrl.estimators)
     importlib.reload(learnrl)
 
 @pytest.fixture
 def hide_tensorflow(reload_tensorflow, mocker):
+    """ Hide tensorflow from the modules list """
     mocker.patch.dict('sys.modules', { 'tensorflow': None })
     importlib.reload(learnrl.callbacks)
-    importlib.reload(learnrl.estimators)
     importlib.reload(learnrl)
 
 @pytest.fixture
 def reload_wandb():
+    """ Reload wandb after hiding it form the modules list """
     yield
     importlib.reload(learnrl.callbacks)
     importlib.reload(learnrl)
 
 @pytest.fixture
 def hide_wandb(reload_wandb, mocker):
+    """ Hide wandb from the modules list """
     mocker.patch.dict('sys.modules', { 'wandb': None })
     importlib.reload(learnrl.callbacks)
     importlib.reload(learnrl)
-
diff --git a/learnrl/environments/multi_agents/nim/__init__.py b/tests/integrations/__init__.py
similarity index 100%
rename from learnrl/environments/multi_agents/nim/__init__.py
rename to tests/integrations/__init__.py
diff --git a/tests/pipelines/__init__.py b/tests/integrations/callbacks/__init__.py
similarity index 100%
rename from tests/pipelines/__init__.py
rename to tests/integrations/callbacks/__init__.py
diff --git a/tests/integrations/callbacks/test_logging.py b/tests/integrations/callbacks/test_logging.py
new file mode 100644
index 0000000..7c0080b
--- /dev/null
+++ b/tests/integrations/callbacks/test_logging.py
@@ -0,0 +1,238 @@
+# LearnRL a python library to learn and use reinforcement learning
+# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
+# pylint: disable=too-many-branches, too-many-statements, missing-function-docstring
+
+""" Test metrics logging behavior """
+
+import pytest
+import numpy as np
+
+from learnrl.callbacks.callback import CallbackList
+from learnrl.callbacks.logging_callback import LoggingCallback
+from learnrl.callbacks import Logger
+
+
+class DummyPlayground():
+    """ Dummy playground for testing """
+
+    def __init__(self, agents=None):
+        self.agents = agents if agents is not None else [0]
+
+    def run(self, callbacks, eps_end_func=None, verbose=0):
+
+        n_episodes = 10
+        steps_cycle_len = 3
+        episodes_cycle_len = 3
+
+        callbacks = CallbackList(callbacks)
+        callbacks.set_params({
+            'verbose':verbose,
+            'episodes': n_episodes,
+            'episodes_cycle_len':episodes_cycle_len,
+            'steps_cycle_len':steps_cycle_len,
+        })
+        callbacks.set_playground(self)
+
+        logs = {}
+        callbacks.on_run_begin(logs)
+
+        for episode in range(n_episodes):
+
+            agent_id = 0
+            logs.update({'agent_id': agent_id})
+
+            if episode % episodes_cycle_len == 0:
+
+                cycle_episode_seen = 0
+                cycle_tracker = {}
+
+                for metric_name in ('reward', 'loss'):
+
+                    cycle_tracker[metric_name] = {'sum_cycle_sum': 0, 'avg_cycle_sum': 0}
+
+                    logs.update({
+                        f'{metric_name}_sum_cycle_sum': None,
+                        f'{metric_name}_avg_cycle_sum': None,
+                        f'{metric_name}_sum_cycle_avg': None,
+                        f'{metric_name}_avg_cycle_avg': None
+                    })
+
+                callbacks.on_episodes_cycle_begin(episode, logs)
+
+            callbacks.on_episode_begin(episode, logs)
+
+            reward_sum = loss_sum = 0
+            n_valid_loss = n_valid_reward = 0
+            n_steps = 10
+
+            for step in range(n_steps):
+
+                if step % steps_cycle_len == 0:
+
+                    steps_cycle_seen = 0
+                    steps_cycle_tracker = {}
+
+                    for metric_name in ('reward', 'loss'):
+                        steps_cycle_tracker[metric_name] = 0
+
+                        logs.update({
+                            f'{metric_name}_steps_sum': None,
+                            f'{metric_name}_steps_avg': None
+                        })
+
+                    callbacks.on_steps_cycle_begin(step, logs)
+
+                callbacks.on_step_begin(step, logs)
+
+                ## Step ##
+
+                reward = 1 + step + 10 * episode
+                reward_sum += reward
+                n_valid_reward += 1
+
+                if step == 0 and episode == 0:
+                    loss = 'N/A'
+                else:
+                    n_valid_loss += 1
+                    loss = 1 / (reward + 1) ** 1.6
+                    loss_sum += loss
+
+                logs.update({'reward': reward})
+                logs.update({'agent_0':{'loss':loss}})
+
+                steps_cycle_tracker['reward'] += reward
+                if not isinstance(loss, str):
+                    steps_cycle_tracker['loss'] += loss
+                steps_cycle_seen += 1
+
+                callbacks.on_step_end(step, logs)
+
+                if (step + 1) % steps_cycle_len == 0 or step == n_steps - 1:
+                    for metric_name in ('reward', 'loss'):
+                        sum_value = steps_cycle_tracker[metric_name]
+                        avg_value = steps_cycle_tracker[metric_name] / steps_cycle_seen
+                        logs.update({
+                            f'{metric_name}_steps_sum': sum_value,
+                            f'{metric_name}_steps_avg': avg_value,
+                        })
+                    callbacks.on_steps_cycle_end(step, logs)
+
+            reward_avg = reward_sum / n_valid_reward
+            loss_avg = loss_sum / n_valid_loss
+
+            for metric_name, metric_avg, metric_sum in zip(
+                ('reward', 'loss'),
+                (reward_avg, loss_avg),
+                (reward_sum, loss_sum),
+            ):
+
+                logs.update({
+                    f'{metric_name}_episode_avg': metric_avg,
+                    f'{metric_name}_episode_sum': metric_sum,
+                    }
+                )
+
+                cycle_tracker[metric_name]['sum_cycle_sum'] += metric_sum
+                cycle_tracker[metric_name]['avg_cycle_sum'] += metric_avg
+
+            ## Done ##
+            callbacks.on_episode_end(episode, logs)
+            cycle_episode_seen += 1
+
+            if (episode + 1) % episodes_cycle_len == 0 or episode == n_episodes - 1:
+
+                for metric_name, metric_avg, metric_sum in zip(
+                        ('reward', 'loss'),
+                        (reward_avg, loss_avg),
+                        (reward_sum, loss_sum),
+                    ):
+
+                    sum_cycle_avg = cycle_tracker[metric_name]['sum_cycle_sum'] / cycle_episode_seen
+                    avg_cycle_avg = cycle_tracker[metric_name]['avg_cycle_sum'] / cycle_episode_seen
+
+                    logs.update({
+                        f'{metric_name}_sum_cycle_sum': cycle_tracker[metric_name]['sum_cycle_sum'],
+                        f'{metric_name}_avg_cycle_sum': cycle_tracker[metric_name]['avg_cycle_sum'],
+                        f'{metric_name}_sum_cycle_avg': sum_cycle_avg,
+                        f'{metric_name}_avg_cycle_avg': avg_cycle_avg
+                    })
+
+                callbacks.on_episodes_cycle_end(episode, logs)
+
+            if eps_end_func is not None:
+                eps_end_func(callbacks, logs)
+
+        callbacks.on_run_end(logs)
+
+
+@pytest.mark.parametrize('cycle_operator', ['avg', 'sum'])
+@pytest.mark.parametrize('metric_name', ['reward', 'loss'])
+def test_logging_steps_operators_(cycle_operator, metric_name):
+
+    print(cycle_operator, metric_name)
+
+    logging_callback = LoggingCallback(
+        metrics=[('reward', {'steps':cycle_operator}), ('loss', {'steps':cycle_operator})],
+    )
+
+    def check_function(callbacks, logs):
+        callback_dict = callbacks.callbacks[0].__dict__
+        expected = logs.get(f'{metric_name}_steps_{cycle_operator}')
+        logged = callback_dict[f'steps_cycle_{metric_name}']
+        if expected is not None:
+            print(metric_name, logged, expected)
+            assert logged != 'N/A' and np.isclose(logged, expected), \
+                f'Logged {logged} instead of {expected}'
+
+    playground = DummyPlayground()
+    playground.run([logging_callback], eps_end_func=check_function, verbose=3)
+    print()
+
+@pytest.mark.parametrize('eps_operator', ['avg', 'sum'])
+@pytest.mark.parametrize('cycle_operator', ['avg', 'sum'])
+@pytest.mark.parametrize('metric_name', ['reward', 'loss'])
+def test_logging_episodes_operators_(eps_operator, cycle_operator, metric_name):
+
+    print(eps_operator, cycle_operator, '\n')
+
+    logging_callback = LoggingCallback(
+        detailed_step_metrics=[],
+        metrics=[('reward', {'episode': eps_operator, 'episodes': cycle_operator}),
+                 ('loss', {'episode': eps_operator, 'episodes': cycle_operator})],
+    )
+
+    def check_function(callbacks, logs):
+        callback_dict = callbacks.callbacks[0].__dict__
+
+        for position in ('episode', 'episodes_cycle'):
+            if position == 'episode':
+                expected = logs.get(f'{metric_name}_episode_{eps_operator}')
+            elif position == 'episodes_cycle':
+                expected = logs.get(f'{metric_name}_{eps_operator}_cycle_{cycle_operator}')
+
+            logged = callback_dict[f'{position}_{metric_name}']
+            if expected is not None:
+                print(position.capitalize(), metric_name, logged, expected)
+                assert logged != 'N/A' and np.isclose(logged, expected), \
+                    f'Logged {logged} instead of {expected}'
+
+    playground = DummyPlayground()
+    playground.run([logging_callback], eps_end_func=check_function, verbose=1)
+    print()
+
+
+def test_display():
+    """ display """
+    for titles_on_top in (False, True):
+        for verbose in range(5):
+
+            logging_callback = Logger(
+                titles_on_top=titles_on_top
+            )
+
+            print(f'Verbose {verbose}, Title_on_top {titles_on_top}\n')
+
+            playground = DummyPlayground()
+            playground.run([logging_callback], verbose=verbose)
+
+            print()
diff --git a/tests/pipelines/tests_agents/__init__.py b/tests/pipelines/tests_agents/__init__.py
deleted file mode 100644
index 8b8e4dd..0000000
--- a/tests/pipelines/tests_agents/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
\ No newline at end of file
diff --git a/tests/pipelines/tests_agents/tests_standard/__init__.py b/tests/pipelines/tests_agents/tests_standard/__init__.py
deleted file mode 100644
index 8ce7997..0000000
--- a/tests/pipelines/tests_agents/tests_standard/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
diff --git a/tests/pipelines/tests_agents/tests_standard/test_agent.py b/tests/pipelines/tests_agents/tests_standard/test_agent.py
deleted file mode 100644
index 2902601..0000000
--- a/tests/pipelines/tests_agents/tests_standard/test_agent.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-import pytest
-
-from learnrl.agents import RandomAgent, StandardAgent
-from learnrl.control import Greedy
-import numpy as np
-from gym.spaces import Discrete, MultiDiscrete
-
-def test_name():
-    agent = StandardAgent(Discrete(3), Discrete(4))
-    default_name = r"standard_greedy_qlearning_{}"
-    if agent.name != default_name:
-        raise ValueError(f'Default name should be {default_name} and not {agent.name}')
-    if str(agent) != default_name:
-        raise ValueError(f'str(BasicAgent) should be {default_name}(agent name) and not {str(agent)}')
-    
-    agent = StandardAgent(Discrete(3), Discrete(4), foo='foo')
-    default_name = r"standard_greedy_qlearning_{'foo': 'foo'}"
-    if agent.name != default_name:
-        raise ValueError(f'Default name should be {default_name} and not {agent.name}')
-
diff --git a/tests/pipelines/tests_agents/tests_standard/test_keras.py b/tests/pipelines/tests_agents/tests_standard/test_keras.py
deleted file mode 100644
index c3816fa..0000000
--- a/tests/pipelines/tests_agents/tests_standard/test_keras.py
+++ /dev/null
@@ -1,67 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-import pytest
-import numpy as np
-
-import learnrl as rl
-from learnrl.environments import CatchEnv
-from learnrl.estimators.tensorflow import KerasEstimator
-from learnrl.agents import StandardAgent
-
-from gym import spaces
-
-import importlib
-tensorflow_spec = importlib.util.find_spec('tensorflow')
-
-if tensorflow_spec is not None:
-    from learnrl.estimators import KerasEstimator
-    import tensorflow as tf
-
-    @pytest.mark.slow
-    def test_fit():
-        class MyEstimator(KerasEstimator):
-
-            def build(self):
-                self.model = tf.keras.experimental.LinearModel(units=self.action_size, use_bias=False)
-                self.model.compile(tf.keras.optimizers.SGD(learning_rate=self.learning_rate), loss='mse')
-
-            def preprocess(self, observations, actions):
-                return tf.one_hot(observations, self.observation_size, on_value=1, off_value=0)
-        
-        observation_size, action_size = 3, 2
-        observations = np.array([0, 2, 0, 1])
-        actions = np.array([0, 0, 1, 1])
-        returns = np.array([1, -1, 1, -1], dtype=np.float32)
-
-        estimator = MyEstimator(spaces.Discrete(observation_size), spaces.Discrete(action_size), learning_rate=len(observations))
-        estimator.fit(observations, actions, returns)
-
-        expected_weights = np.array([[1, 1], [0, -1],[-1, 0]], dtype=np.float32)
-        new_returns = estimator.predict(observations).numpy()[np.arange(len(actions)), actions]
-
-        print(new_returns, returns)
-        assert np.allclose(new_returns, returns)
-        print(estimator.model.weights[0].numpy(), expected_weights)
-        assert np.allclose(estimator.model.weights[0].numpy(), expected_weights)
-
-    @pytest.mark.slow
-    def test_keras_pipeline():
-        class MyEstimator(KerasEstimator):
-
-            def build(self):
-                self.model = tf.keras.models.Sequential()
-                self.model.add(tf.keras.layers.Dense(self.observation_size, activation='relu', input_shape=self.observation_shape))
-                self.model.add(tf.keras.layers.Dense(self.action_size))
-
-                self.model.compile(tf.keras.optimizers.Adam(learning_rate=self.learning_rate), loss='mse')
-
-            def preprocess(self, observations, actions):
-                return observations
-
-        env = CatchEnv(n_apples=1)
-        custom_action_value = MyEstimator(observation_space=env.observation_space, action_space=env.action_space, batch_size=64, freezed_steps=20)
-        agent = StandardAgent(observation_space=env.observation_space, action_space=env.action_space, action_values=custom_action_value)
-
-        pg = rl.Playground(env, agent)
-        pg.fit(2)
diff --git a/tests/pipelines/tests_agents/tests_standard/test_numpy.py b/tests/pipelines/tests_agents/tests_standard/test_numpy.py
deleted file mode 100644
index 1360fb4..0000000
--- a/tests/pipelines/tests_agents/tests_standard/test_numpy.py
+++ /dev/null
@@ -1,79 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-import pytest
-import numpy as np
-np.random.seed(42)
-
-from copy import deepcopy
-
-from learnrl import Playground, Memory
-
-from learnrl.environments import RdNimEnv
-from learnrl.agents import StandardAgent
-from learnrl.evaluation import MonteCarlo, TemporalDifference, QLearning
-from learnrl.control import Greedy, Ucb, Puct
-
-
-@pytest.fixture
-def n_triplets():
-    return 3
-
-@pytest.fixture
-def env(n_triplets):
-    return RdNimEnv(initial_state=n_triplets*3 + 2, is_optimal=True)
-
-@pytest.fixture
-def pertinent_observations(n_triplets):
-    return np.concatenate([[2+k+4*i for k in range(3)] for i in range(n_triplets-1)])
-
-@pytest.fixture
-def expected_optimal_actions(n_triplets):
-    return np.concatenate([(0, 1, 2) for _ in range(n_triplets-1)])
-
-def test_evalutations(env, pertinent_observations, expected_optimal_actions):
-    evaluations = [
-        (QLearning(learning_rate=0.3), True),
-        (QLearning(learning_rate=0.3), False),
-        (TemporalDifference(learning_rate=0.3), True),
-        (TemporalDifference(learning_rate=0.3), False),
-        (MonteCarlo(learning_rate=0.3), False),
-    ]
-    for evaluation, online in evaluations:
-        agent = StandardAgent(observation_space=env.observation_space, action_space=env.action_space,
-                        evaluation=evaluation, online=online,
-                        control=Greedy(exploration=0.))
-        n_games = 50
-        pg = Playground(env, agent)
-        pg.fit(n_games, verbose=1)
-
-        n_sticks = env.observation_space.n
-        greedy_actions = np.zeros(n_sticks)
-        for i in range(n_sticks):
-            greedy_actions[i] = agent.act(i, greedy=True)
-        pertinent_actions = greedy_actions[pertinent_observations]
-
-        if not np.all(pertinent_actions==expected_optimal_actions):
-            raise ValueError(f'Got policy {pertinent_actions} instead of {expected_optimal_actions} for evaluation {str(evaluation)}')
-
-def test_controls(env, pertinent_observations, expected_optimal_actions):
-    controls = [
-        Greedy(exploration=0.1),
-        Ucb(exploration=0.1),
-        Puct(exploration=0.05),
-    ]
-    for control in controls:
-        agent = StandardAgent(observation_space=env.observation_space, action_space=env.action_space,
-                        evaluation=QLearning(learning_rate=0.3), online=True, control=control)
-        n_games = 50
-        pg = Playground(env, agent)
-        pg.fit(n_games, verbose=1)
-
-        n_sticks = env.observation_space.n
-        greedy_actions = np.zeros(n_sticks)
-        for i in range(n_sticks):
-            greedy_actions[i] = agent.act(i, greedy=True)
-        pertinent_actions = greedy_actions[pertinent_observations]
-
-        if not np.all(pertinent_actions==expected_optimal_actions):
-            raise ValueError(f'Got policy {pertinent_actions} instead of {expected_optimal_actions} for control {str(control)}')
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
index 486f7aa..8ce7997 100644
--- a/tests/unit/__init__.py
+++ b/tests/unit/__init__.py
@@ -1,3 +1,2 @@
 # LearnRL a python library to learn and use reinforcement learning
 # Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
diff --git a/tests/unit/callbacks/test_callback.py b/tests/unit/callbacks/test_callback.py
new file mode 100644
index 0000000..a788d5b
--- /dev/null
+++ b/tests/unit/callbacks/test_callback.py
@@ -0,0 +1,141 @@
+# LearnRL a python library to learn and use reinforcement learning
+# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
+# pylint: disable=protected-access, attribute-defined-outside-init, missing-function-docstring
+
+""" Test Callback abstract classes """
+
+import pytest
+import pytest_check as check
+from learnrl.callbacks.callback import Callback, CallbackList
+
+class TestCallback:
+
+    """ Callback """
+
+    def test_init(self):
+        """ should instanciate correctly. """
+        callback = Callback()
+        check.equal(callback.params, {})
+        check.is_none(callback.playground)
+
+    def test_set_params(self):
+        """ should update params correctly. """
+        callback = Callback()
+        expected_params = {"param": 1}
+        callback.set_params(expected_params)
+        check.equal(callback.params, expected_params)
+
+    def test_set_playground(self):
+        """ should update playground correctly. """
+        callback = Callback()
+        expected_playground = "playground"
+        callback.set_playground(expected_playground)
+        check.equal(callback.playground, expected_playground)
+
+    @pytest.mark.parametrize('hook', ['begin', 'end'])
+    @pytest.mark.parametrize('timescale', ['step', 'steps_cycle',
+        'episode', 'episodes_cycle', 'run'])
+    def test_callback_on_(self, timescale, hook):
+        callback = Callback()
+        callback_hook = getattr(callback, f'on_{timescale}_{hook}')
+        if timescale in ['step', 'steps_cycle']:
+            callback_hook(step=7, logs={'log': 'info'})
+        elif timescale in ['episode', 'episodes_cycle']:
+            callback_hook(episode=7, logs={'log': 'info'})
+        else:
+            callback_hook(logs={'log': 'info'})
+
+
+class TestCallbackList:
+
+    """ CallbackList """
+
+    @pytest.fixture(autouse=True)
+    def setup(self):
+        self.callback1 = Callback()
+        self.callback2 = Callback()
+        self.callbacks = [self.callback1, self.callback2]
+        self.callback_path = 'learnrl.callbacks.callback.Callback'
+
+    def test_init(self):
+        """ should instanciate correctly. """
+        callbacks = CallbackList(self.callbacks)
+        check.equal(callbacks.params, {})
+        check.is_none(callbacks.playground)
+
+    def test_set_params(self, mocker):
+        """ should update params of all callbacks correctly. """
+        mocker.patch(self.callback_path + '.set_params')
+
+        callbacks = CallbackList(self.callbacks)
+        expected_params = {"param": 1}
+        callbacks.set_params(expected_params)
+
+        check.equal(callbacks.params, expected_params)
+        for callback in self.callbacks:
+            args, _ = callback.set_params.call_args
+            check.equal(args[0], expected_params)
+
+    def test_set_playground(self, mocker):
+        """ should update playground of all callbacks correctly. """
+        mocker.patch(self.callback_path + '.set_playground')
+
+        callbacks = CallbackList(self.callbacks)
+        expected_playground = "playground"
+        callbacks.set_playground(expected_playground)
+
+        check.equal(callbacks.playground, expected_playground)
+        for callback in self.callbacks:
+            args, _ = callback.set_playground.call_args
+            check.equal(args[0], expected_playground)
+
+    def test_call_key_hook_no_callback(self):
+        """ _call_key_hook should do nothing if no callbacks. """
+        callbacks = CallbackList([])
+        callbacks._call_key_hook('timescale', 'at')
+
+    @pytest.mark.parametrize('hook', ['begin', 'end'])
+    @pytest.mark.parametrize('timescale', ['step', 'steps_cycle',
+        'episode', 'episodes_cycle', 'run'])
+    def test_call_key_hook(self, mocker, timescale, hook):
+        time = 0.1 if hook == 'begin' else 0.123
+        value = 17 if timescale != 'run' else None
+        logs = {'log': 7}
+        t_begin_name = f't_{timescale}_begin'
+        dt_name = f'dt_{timescale}'
+        hook_name = f'on_{timescale}_{hook}'
+
+        mocker.patch('time.time', return_value=time)
+        mocker.patch('learnrl.callbacks.callback.Callback.' + hook_name)
+
+        callbacks = CallbackList(self.callbacks)
+        if hook == 'end':
+            setattr(callbacks, t_begin_name, 0.1)
+
+        callbacks._call_key_hook(timescale, hook, value=value, logs=logs)
+
+        if hook == 'end':
+            check.almost_equal(logs.get(dt_name), 0.023)
+
+        for callback in self.callbacks:
+            check.is_true(getattr(callback, hook_name).called)
+
+
+    @pytest.mark.parametrize('hook', ['begin', 'end'])
+    @pytest.mark.parametrize('timescale', ['step', 'steps_cycle',
+        'episode', 'episodes_cycle', 'run'])
+    def test_calls_all_callbacks_on_(self, timescale, hook, mocker):
+        mocker.patch('learnrl.callbacks.callback.CallbackList._call_key_hook')
+
+        callbacks = CallbackList(self.callbacks)
+        callback_hook = getattr(callbacks, f'on_{timescale}_{hook}')
+        if timescale in ['step', 'steps_cycle']:
+            callback_hook(step=7, logs={'log': 'info'})
+        elif timescale in ['episode', 'episodes_cycle']:
+            callback_hook(episode=7, logs={'log': 'info'})
+        else:
+            callback_hook(logs={'log': 'info'})
+
+        args, _ = callbacks._call_key_hook.call_args
+        check.equal(args[0], timescale)
+        check.equal(args[1], hook)
diff --git a/tests/unit/callbacks/test_logging.py b/tests/unit/callbacks/test_logging.py
index cc4f801..4bf1b01 100644
--- a/tests/unit/callbacks/test_logging.py
+++ b/tests/unit/callbacks/test_logging.py
@@ -1,232 +1,609 @@
 # LearnRL a python library to learn and use reinforcement learning
 # Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-import pytest, sys
-import numpy as np
-
-from learnrl.callbacks import LoggingCallback, Callback, CallbackList
-
-
-def test_extract_from_logs():
-
-    extract_from_logs = LoggingCallback._extract_metric_from_logs
-
-    logs = {
-        'value': 0,
-        'step': 2,
-        'agent_0': {
-            'value': 1,
-        },
-        'agent_1': {
-            'value': 2,
-            'specific_value': 42,
+# pylint: disable=protected-access, attribute-defined-outside-init, unused-argument, missing-function-docstring
+
+""" Test metrics logging behavior """
+
+import pytest_check as check
+import pytest
+
+from learnrl.callbacks.logging_callback import LoggingCallback, Metric, MetricList
+
+class TestMetric:
+    """ Metric """
+
+    def test_init_full(self):
+        """ should instanciate correctly with a full metric code. """
+        metric = Metric('reward~rwd.sum')
+        check.equal(metric.name, 'reward',
+            f'Metric name should be reward and not {metric.name}'
+        )
+        check.equal(metric.surname, 'rwd',
+            f'Metric surname should be rwd and not {metric.surname}'
+        )
+        check.equal(metric.operator, 'sum',
+            f'Metric opertor should be sum and not {metric.operator}'
+        )
+
+
+    def test_init_no_op(self):
+        """ should instanciate correctly without an operator. """
+        metric = Metric('reward~rwd')
+        check.equal(metric.name, 'reward',
+            f'Metric name should be reward and not {metric.name}'
+        )
+        check.equal(metric.surname, 'rwd',
+            f'Metric surname should be rwd and not {metric.surname}'
+        )
+        check.equal(metric.operator, 'avg',
+            f'Metric opertor should be avg and not {metric.operator}'
+        )
+
+    def test_init_no_surname(self):
+        """ should instanciate correctly without a surname. """
+        metric = Metric('reward.sum')
+        check.equal(metric.name, 'reward',
+            f'Metric name should be reward and not {metric.name}'
+        )
+        check.equal(metric.surname, 'reward',
+            f'Metric surname should be reward and not {metric.surname}'
+        )
+        check.equal(metric.operator, 'sum',
+            f'Metric opertor should be sum and not {metric.operator}'
+        )
+
+    def test_init_name_only(self):
+        """ should instanciate correctly with a name only. """
+        metric = Metric('reward')
+        check.equal(metric.name, 'reward',
+            f'Metric name should be reward and not {metric.name}'
+        )
+        check.equal(metric.surname, 'reward',
+            f'Metric surname should be reward and not {metric.surname}'
+        )
+        check.equal(metric.operator, 'avg',
+            f'Metric opertor should be avg and not {metric.operator}'
+        )
+
+    def test_equal_str(self):
+        """ should be equal to a string equal to its name only. """
+        metric = Metric('reward~rwd.sum')
+        check.is_true(metric == 'reward')
+        check.is_false(metric == 'rewards')
+        check.is_false(metric == 'rwd')
+
+    def test_equal_metric(self):
+        """ should be equal to a metric with same name. """
+        metric_sum = Metric('reward~rwd.sum')
+        metric_avg = Metric('reward~R.avg')
+        check.is_true(metric_sum == metric_avg)
+
+    def test_str(self):
+        """ should be represented as name with str. """
+        metric = Metric('reward~rwd.sum')
+        check.is_true(str(metric) == 'reward')
+
+    def test_repr(self):
+        """ should be represented as full metric code with repr. """
+        metric = Metric('reward~rwd.sum')
+        check.is_true(repr(metric) == 'reward~rwd.sum')
+
+
+class TestMetricList:
+    """ MetricList """
+
+    @pytest.fixture(autouse=True)
+    def setup_metrics(self):
+        """ Setup metrics for tests """
+        self.metric_codes = ['reward~rwd.sum', 'loss_1', 'loss_2.sum']
+        self.metrics = [Metric(code) for code in self.metric_codes]
+
+    def test_init_metrics(self):
+        """ should instanciate correclty with a list of Metric. """
+        metriclist = MetricList(self.metrics)
+        check.equal(metriclist.metric_list, self.metrics)
+        check.equal(metriclist.metric_names, [metric.name for metric in self.metrics])
+
+    def test_init_codes(self):
+        """ should instanciate correclty with a list of metric codes. """
+        metriclist = MetricList(self.metrics)
+        check.equal(metriclist.metric_list, self.metrics)
+        check.equal(metriclist.metric_names, [metric.name for metric in self.metrics])
+
+    def test_str(self):
+        """ should be represented correctly to str. """
+        metriclist = MetricList(self.metrics)
+        check.equal(str(metriclist), str(self.metrics))
+
+    def test_add_metric(self):
+        """ should add correclty a new metric. """
+        metriclist = MetricList(self.metrics)
+        metric = Metric('exploration~exp.last')
+        metriclist += metric
+        check.equal(metriclist.metric_names, self.metrics + [metric])
+        expected_metric_names = [metric.name for metric in self.metrics] + ['exploration']
+        check.equal(metriclist.metric_names, expected_metric_names)
+
+    def test_add_metric_code(self):
+        """ should add correclty a new metric code. """
+        metriclist = MetricList(self.metrics)
+        metric = Metric('exploration~exp.last')
+        metriclist += 'exploration~exp.last'
+        check.equal(metriclist.metric_names, self.metrics + [metric])
+        expected_metric_names = [metric.name for metric in self.metrics] + ['exploration']
+        check.equal(metriclist.metric_names, expected_metric_names)
+
+    def test_add_metric_list(self):
+        """ should add correclty a list of Metric. """
+        metriclist = MetricList(self.metrics)
+        exploration = Metric('exploration~exp.last')
+        decay = Metric('decay.last')
+        metriclist += [exploration, decay]
+        check.equal(metriclist.metric_names, self.metrics + [exploration, decay])
+        expected_metric_names = [metric.name for metric in self.metrics] + ['exploration', 'decay']
+        check.equal(metriclist.metric_names, expected_metric_names)
+
+    def test_add_metric_codes_list(self):
+        """ should add correclty a new list of metric codes. """
+        metriclist = MetricList(self.metrics)
+        exploration = Metric('exploration~exp.last')
+        decay = Metric('decay.last')
+        metriclist += ['exploration~exp.last', 'decay.last']
+        check.equal(metriclist.metric_names, self.metrics + [exploration, decay])
+        expected_metric_names = [metric.name for metric in self.metrics] + ['exploration', 'decay']
+        check.equal(metriclist.metric_names, expected_metric_names)
+
+    def test_add_metriclist(self):
+        """ should concatenate correclty two MetricList. """
+        metriclist = MetricList(self.metrics)
+        exploration = Metric('exploration~exp.last')
+        decay = Metric('decay.last')
+        metriclist += MetricList([exploration, decay])
+        check.equal(metriclist.metric_names, self.metrics + [exploration, decay])
+        expected_metric_names = [metric.name for metric in self.metrics] + ['exploration', 'decay']
+        check.equal(metriclist.metric_names, expected_metric_names)
+
+
+class TestLoggingCallback:
+    """ LoggingCallback """
+
+    @pytest.fixture(autouse=True)
+    def setup(self):
+        """ Setup fixtures. """
+        self.logging_callback_path = "learnrl.callbacks.logging_callback.LoggingCallback"
+
+    def test_init(self):
+        """ should instanciate correctly. """
+        LoggingCallback()
+
+    def test_reset_attr_avg(self):
+        """ should reset arguments correctly. """
+        logging_callback = LoggingCallback()
+        logging_callback.reward = 0
+        logging_callback._reset_attr('reward', 'avg')
+        check.equal(logging_callback.reward, 'N/A')
+        check.equal(logging_callback.reward_seen, 0)
+
+    def test_reset_attr_anyop(self):
+        """ should reset arguments correctly. """
+        logging_callback = LoggingCallback()
+        logging_callback.reward = 0
+        logging_callback._reset_attr('reward', 'x')
+        check.equal(logging_callback.reward, 'N/A')
+
+    def test_update_attr_avg(self):
+        """ should update arguments correctly with avg operator. """
+        logging_callback = LoggingCallback()
+        logging_callback.reward = 0
+        logging_callback.reward_seen = 2
+        logging_callback._update_attr('reward', 1, 'avg')
+        check.equal(logging_callback.reward, 1/3)
+        check.equal(logging_callback.reward_seen, 3)
+
+        # With N/A
+        logging_callback = LoggingCallback()
+        logging_callback.reward = 'N/A'
+        logging_callback.reward_seen = 0
+        logging_callback._update_attr('reward', 1, 'avg')
+        check.equal(logging_callback.reward, 1)
+        check.equal(logging_callback.reward_seen, 1)
+
+    def test_update_attr_sum(self):
+        """ should update arguments correctly with sum operator. """
+        logging_callback = LoggingCallback()
+        logging_callback.reward = 2
+        logging_callback._update_attr('reward', 1, 'sum')
+        check.equal(logging_callback.reward, 3)
+
+    def test_update_attr_last(self):
+        """ should update arguments correctly with last operator. """
+        logging_callback = LoggingCallback()
+        logging_callback.reward = 2
+        logging_callback._update_attr('reward', 1, 'last')
+        check.equal(logging_callback.reward, 1)
+
+    def test_update_attr_raise(self):
+        """ should raise ValueError if operator is unknowed. """
+        with pytest.raises(ValueError, match=r"Unknowed operator.*"):
+            logging_callback = LoggingCallback()
+            logging_callback.reward = 'N/A'
+            logging_callback._update_attr('reward', 1, 'x')
+
+    def test_on_run_begin(self, mocker):
+        """ should set n_agents on_run_begin. """
+        class DummyPlayground():
+            """DummyPlaygrounf"""
+            def __init__(self, n_agents):
+                self.agents = [None] * n_agents
+
+        n_agents = 5
+        mocker.patch(self.logging_callback_path + '', return_value=n_agents)
+        logging_callback = LoggingCallback()
+        logging_callback.playground = DummyPlayground(n_agents)
+        check.is_none(logging_callback.n_agents)
+        logging_callback.on_run_begin()
+        check.equal(logging_callback.n_agents, n_agents)
+
+    def test_on_episodes_cycle_begin(self, mocker):
+        """ should reset episodes_cycle_metrics on_episodes_cycle_begin. """
+        mocker.patch(self.logging_callback_path + '._update_metrics_all_agents')
+        logging_callback = LoggingCallback()
+        logging_callback.on_episodes_cycle_begin(episode=7)
+        args, kwargs = logging_callback._update_metrics_all_agents.call_args
+        check.equal(args[1], 'episodes_cycle')
+        check.is_true(kwargs.get('reset'))
+
+    def test_on_episode_begin(self, mocker):
+        """ should reset episode_metrics on_episode_begin. """
+        mocker.patch(self.logging_callback_path + '._update_metrics_all_agents')
+        logging_callback = LoggingCallback()
+        logging_callback.on_episode_begin(episode=7)
+        args, kwargs = logging_callback._update_metrics_all_agents.call_args
+        check.equal(args[1], 'episode')
+        check.is_true(kwargs.get('reset'))
+
+    def test_on_step_cycle_begin(self, mocker):
+        """ should reset steps_cycle_metrics on step_cycle_begin. """
+        mocker.patch(self.logging_callback_path + '._update_metrics_all_agents')
+        logging_callback = LoggingCallback()
+        logging_callback.on_steps_cycle_begin(step=7)
+        args, kwargs = logging_callback._update_metrics_all_agents.call_args
+        check.equal(args[1], 'steps_cycle')
+        check.is_true(kwargs.get('reset'))
+
+    def test_on_episode_end(self, mocker):
+        """ should update episodes_cycle_metrics on_episode_end from episode metrics. """
+        mocker.patch(self.logging_callback_path + '._update_metrics_all_agents')
+        logging_callback = LoggingCallback()
+        logging_callback.on_episode_end(episode=7)
+        args, kwargs = logging_callback._update_metrics_all_agents.call_args
+        check.equal(args[1], 'episodes_cycle')
+        check.is_false(kwargs.get('reset'))
+        check.equal(kwargs.get('source_prefix'), 'episode')
+
+    def test_on_step_end(self, mocker):
+        """ should update episode_metrics for the current agent and
+            steps_cycle_metrics for all agents on_step_end. """
+        mocker.patch(self.logging_callback_path + '._update_metrics_all_agents')
+        mocker.patch(self.logging_callback_path + '._update_metrics')
+
+        logging_callback = LoggingCallback()
+        logging_callback.n_agents = 1
+        logging_callback.on_step_end(step=7)
+
+        args, kwargs = logging_callback._update_metrics.call_args
+        check.equal(args[1], 'episode')
+        check.is_false(kwargs.get('reset'))
+        check.is_none(kwargs.get('agent_id'))
+
+        args, kwargs = logging_callback._update_metrics_all_agents.call_args
+        check.equal(args[1], 'steps_cycle')
+        check.is_false(kwargs.get('reset'))
+
+        # Multi agents
+        logging_callback = LoggingCallback()
+        logging_callback.n_agents = 5
+        logging_callback.on_step_end(step=7, logs={'agent_id': 3})
+
+        args, kwargs = logging_callback._update_metrics.call_args
+        check.equal(args[1], 'episode')
+        check.is_false(kwargs.get('reset'))
+        check.equal(kwargs.get('agent_id'), 3)
+
+        args, kwargs = logging_callback._update_metrics_all_agents.call_args
+        check.equal(args[1], 'steps_cycle')
+        check.is_false(kwargs.get('reset'))
+
+
+class TestGetAttrName:
+    """ LoggingCallback._get_attr_name """
+
+    @pytest.fixture(autouse=True)
+    def setup(self):
+        """ Retrieve static function to test. """
+        self.get_attr_name = LoggingCallback._get_attr_name
+
+    def test_without_agent(self):
+        """ should name attrs correctly. """
+        metric = Metric('reward~rwd')
+        name = self.get_attr_name('prefix', metric, agent_id=None)
+        expected_name = 'prefix_reward'
+        check.equal(name, expected_name)
+
+    def test_with_specific_agent(self):
+        """ should name attrs correctly with specific agent. """
+        metric = Metric('reward~rwd')
+        name = self.get_attr_name('prefix', metric, agent_id=2)
+        expected_name = 'prefix_agent2_reward'
+        check.equal(name, expected_name)
+
+
+class TestExtractFromLogs:
+    """ LoggingCallback._extract_from_logs """
+
+    @pytest.fixture(autouse=True)
+    def setup(self):
+        """ Setup fake logs and retrieve function to test. """
+        self.extract_from_logs = LoggingCallback._extract_metric_from_logs
+        self.logs = {
+            'value': 0,
+            'step': 2,
+            'agent_0': {
+                'value': 1,
+            },
+            'agent_1': {
+                'value': 2,
+                'specific_value': 42,
+            }
         }
-    }
-
-    # We should find values
-    value = extract_from_logs('value', logs)
-    assert value == 0
-
-    # Nothing should return N/A
-    nothing = extract_from_logs('nothing', logs)
-    assert nothing == 'N/A'
-
-    # If no agent is specified, it should find any specific_value in agent
-    specific_value = extract_from_logs('specific_value', logs)
-    assert specific_value == 42
-
-    # We should find specific agent values
-    value_0 = extract_from_logs('value', logs, agent_id=0)
-    assert value_0 == 1
-    value_1 = extract_from_logs('value', logs, agent_id=1)
-    assert value_1 == 2
-
-    # When agent is specified, it should return outer value
-    # if a specific value is not found
-    step = extract_from_logs('step', logs, agent_id=0)
-    assert step == 2
-
-
-
-class DummyPlayground():
-
-    def __init__(self, agents=[0]):
-        self.agents = agents
-
-    def run(self, callbacks, eps_end_func=None, verbose=0):
-
-        n_episodes = 10
-
-        callbacks = CallbackList(callbacks)
-        callbacks.set_params({'verbose':verbose, 'episodes': n_episodes})
-        callbacks.set_playground(self)
-
-        logs = {}
-        callbacks.on_run_begin(logs)
-        
-        cycle_len = 3
-
-        for episode in range(n_episodes):
-
-            agent_id = 0
-            logs.update({'agent_id': agent_id})
-
-            if episode % cycle_len == 0:
-
-                cycle_episode_seen = 0
-                cycle_tracker = {}
-
-                for metric_name in ('reward', 'loss'):
-
-                    cycle_tracker[metric_name] = {'sum_cycle_sum': 0, 'avg_cycle_sum': 0}
-
-                    logs.update({
-                        f'{metric_name}_sum_cycle_sum': None,
-                        f'{metric_name}_avg_cycle_sum': None,
-                        f'{metric_name}_sum_cycle_avg': None,
-                        f'{metric_name}_avg_cycle_avg': None
-                    })
-
-                callbacks.on_cycle_begin(episode, logs)
-
-            callbacks.on_episode_begin(episode, logs)
-
-            reward_sum = loss_sum = 0
-            n_valid_loss = n_valid_reward = 0
-            n_steps = 10
-
-            for step in range(n_steps):
 
-                if step % 3 == 0:
-                    # callbacks.on_step_cycle_begin(step, logs)
-                    pass
-                
-                callbacks.on_step_begin(step, logs)
-
-                ## Step ##
-
-                reward = 1 + step + 10 * episode
-                reward_sum += reward
-                n_valid_reward += 1
-
-                if step == 0 and episode == 0:
-                    loss = 'N/A'
-                else:
-                    n_valid_loss += 1
-                    loss = 1 / (reward + 1) ** 1.6
-                    loss_sum += loss
-
-                logs.update({'reward': reward})
-                logs.update({'agent_0':{'loss':loss}})
-
-                callbacks.on_step_end(step, logs)
-
-                if step % 3 == 2 or step == n_steps - 1:
-                    # callbacks.on_step_cycle_end(step, logs)
-                    pass
-            
-            reward_avg = reward_sum / n_valid_reward
-            loss_avg = loss_sum / n_valid_loss
-
-            for metric_name, metric_avg, metric_sum in zip(
-                ('reward', 'loss'),
-                (reward_avg, loss_avg),
-                (reward_sum, loss_sum), 
-            ):
-
-                logs.update({
-                    f'{metric_name}_episode_avg': metric_avg,
-                    f'{metric_name}_episode_sum': metric_sum,
-                    }
-                )
-
-                cycle_tracker[metric_name]['sum_cycle_sum'] += metric_sum
-                cycle_tracker[metric_name]['avg_cycle_sum'] += metric_avg
-
-            ## Done ##
-            callbacks.on_episode_end(episode, logs)
-            cycle_episode_seen += 1
-
-            if (episode + 1) % cycle_len == 0 or episode == n_episodes - 1:
-
-                for metric_name, metric_avg, metric_sum in zip(
-                        ('reward', 'loss'),
-                        (reward_avg, loss_avg),
-                        (reward_sum, loss_sum), 
-                    ):                   
-
-                    sum_cycle_avg = cycle_tracker[metric_name]['sum_cycle_sum'] / cycle_episode_seen
-                    avg_cycle_avg = cycle_tracker[metric_name]['avg_cycle_sum'] / cycle_episode_seen
-
-                    logs.update({
-                        f'{metric_name}_sum_cycle_sum': cycle_tracker[metric_name]['sum_cycle_sum'],
-                        f'{metric_name}_avg_cycle_sum': cycle_tracker[metric_name]['avg_cycle_sum'],
-                        f'{metric_name}_sum_cycle_avg': sum_cycle_avg,
-                        f'{metric_name}_avg_cycle_avg': avg_cycle_avg
-                    })
-
-                callbacks.on_cycle_end(episode, logs)
-            
-            if eps_end_func is not None: eps_end_func(callbacks, logs)
-            
-        callbacks.on_run_end(logs)
-
-
-def test_logging_avg_sum():
-
-    for eps_operator in ['avg', 'sum']:
-
-        for cycle_operator in ['avg', 'sum']:
-
-            print(eps_operator, cycle_operator, '\n')
-
-            logging_callback = LoggingCallback(
-                detailed_step_only_metrics=[],
-                step_metrics=['reward', 'loss'],
-                episode_only_metrics=[], 
-                episode_metrics=[f'reward.{eps_operator}', f'loss.{eps_operator}'],
-                cycle_only_metrics=[],
-                cycle_metrics=[f'reward.{cycle_operator}', f'loss.{cycle_operator}'],
+    def test_find_value(self):
+        """ should find value in logs. """
+        value = self.extract_from_logs('value', self.logs)
+        check.equal(value, 0)
+
+    def test_find_nothing(self):
+        """ should return N/A when there is no value. """
+        nothing = self.extract_from_logs('nothing', self.logs)
+        check.equal(nothing, 'N/A')
+
+    def test_find_any_specific_value(self):
+        """ should find any specific value in agent when no agent is specified. """
+        specific_value = self.extract_from_logs('specific_value', self.logs)
+        check.equal(specific_value, 42)
+
+    def test_find_agent_specific_value(self):
+        """ should find specific agent values. """
+        value_0 = self.extract_from_logs('value', self.logs, agent_id=0)
+        value_1 = self.extract_from_logs('value', self.logs, agent_id=1)
+
+        check.equal(value_0, 1)
+        check.equal(value_1, 2)
+
+    def test_return_outer_value_when_no_specific_agent_value(self):
+        """ should return outer value if no specific value is found when an agent is specified. """
+        step = self.extract_from_logs('step', self.logs, agent_id=0)
+        check.equal(step, 2)
+
+
+class TestLoggingCallbackExtractLists:
+    """ LoggingCallback._extract_lists """
+
+    @pytest.fixture(autouse=True)
+    def setup_logs(self):
+        """ Setup fake logs and retrieve function to test. """
+        self.extract_lists = LoggingCallback._extract_lists
+
+    def test_extract_lists_use_case(self):
+        """ should extract the correct metrics list. """
+
+        metrics = [
+            ('reward~rwd', {'steps': 'sum', 'episode': 'sum'}),
+            ('loss', {'episodes': 'last'}),
+            'exploration~exp.last',
+            'decay'
+        ]
+
+        metric_lists = self.extract_lists(metrics)
+
+        expected_metric_lists = [
+            ['reward~rwd', 'loss', 'exploration~exp', 'decay'],
+            ['reward~rwd.sum', 'loss.avg', 'exploration~exp.last', 'decay.avg'],
+            ['reward~rwd.sum', 'loss.avg', 'exploration~exp.last', 'decay.avg'],
+            ['reward~rwd.avg', 'loss.last', 'exploration~exp.last', 'decay.avg']
+        ]
+
+        metric_lists_names = [
+            'step_metrics',
+            'steps_cycle_metrics',
+            'episode_metrics',
+            'episodes_cycle_metrics'
+        ]
+
+        iterator = zip(metric_lists, expected_metric_lists, metric_lists_names)
+        for metric_list, expected_metric_list, metric_list_name in iterator:
+            check.equal(metric_list, expected_metric_list,
+                f'Unexpected metric list got {metric_list} ' \
+                f'instead of {expected_metric_list} for {metric_list_name}'
             )
 
-            def check_function(callbacks, logs):
-                callback_dict = callbacks.callbacks[0].__dict__
-
-                for position in ('episode', 'cycle'):
-                    for metric_name in ('reward', 'loss'):
+    def test_extract_lists_wrong_format(self):
+        """ should raise ValueError on wrong metric nested format. """
+        metrics = ('reward')
 
-                        if position == 'episode':
-                            expected = logs.get(f'{metric_name}_{position}_{eps_operator}')
-                        elif position == 'cycle':
-                            expected = logs.get(f'{metric_name}_{eps_operator}_{position}_{cycle_operator}')
-
-                        logged = callback_dict[f'{position}_{metric_name}']
-                        if expected is not None:
-                            print(position.capitalize(), metric_name, logged, expected)
-                            assert logged != 'N/A'
-                            assert np.isclose(logged, expected)
-                        
-            pg = DummyPlayground()
-            pg.run([logging_callback], eps_end_func=check_function, verbose=1)
-            print()
-
-
-def test_display():
-
-    from learnrl.callbacks import Logger
-
-    for titles_on_top in (False, True):
-        for verbose in range(4):
-
-            logging_callback = Logger(
-                titles_on_top=titles_on_top
-            )
+        with pytest.raises(ValueError, match=r".*metrics format.*"):
+            self.extract_lists(metrics)
 
-            print(f'Verbose {verbose}, Title_on_top {titles_on_top}\n')
 
-            pg = DummyPlayground()
-            pg.run([logging_callback], verbose=verbose)
+class TestLoggingCallbackGetValue:
+    """ LoggingCallback._get_value """
 
-            print()
-            
-    assert True
+    def test_get_value_with_prefix(self, mocker):
+        """ should return attr value when prefix is given. """
+        expected_value = 123
+
+        def _get_attr_name(*args):
+            return 'attribute'
+
+        mocker.patch(
+            'learnrl.callbacks.logging_callback.LoggingCallback._get_attr_name',
+            _get_attr_name
+        )
+
+        logging_callback = LoggingCallback()
+        logging_callback.attribute = expected_value
+
+        metric = Metric('attribute')
+        value = logging_callback._get_value(metric, prefix='prefix')
+
+        check.equal(value, expected_value)
+
+    def test_get_value_no_prefix_logs(self, mocker):
+        """ should return value in logs when no prefix is given. """
+        expected_value = 123
+
+        def _extract_metric_from_logs(self, metric_name, logs, agent_id):
+            return logs[metric_name]
+
+        mocker.patch(
+            'learnrl.callbacks.logging_callback.LoggingCallback._extract_metric_from_logs',
+            _extract_metric_from_logs
+        )
+
+        logging_callback = LoggingCallback()
+        logs = {'attribute': 123}
+
+        metric = Metric('attribute')
+        value = logging_callback._get_value(metric, logs=logs)
+
+        check.equal(value, expected_value)
+
+    def test_get_value_no_prefix_no_logs(self, mocker):
+        """ should return N/A when nothing is found. """
+        expected_value = 'N/A'
+        logging_callback = LoggingCallback()
+        metric = Metric('attribute')
+        value = logging_callback._get_value(metric)
+        check.equal(value, expected_value)
+
+
+class TestLoggingCallbackUpdateMetrics:
+    """ LoggingCallback._update_metrics """
+
+    @pytest.fixture(autouse=True)
+    def setup(self):
+        self.metric_list = MetricList([
+            Metric("metric_1"),
+            Metric("metric_2"),
+            Metric("metric_2")
+        ])
+
+        self.logging_callback = LoggingCallback()
+
+    def test_na_value(self, mocker):
+        """ should not update attr if value is N/A. """
+        logging_callback_path = 'learnrl.callbacks.logging_callback.LoggingCallback'
+        mocker.patch(logging_callback_path + '._get_attr_name', return_value="target_name")
+        mocker.patch(logging_callback_path + '._reset_attr')
+        mocker.patch(logging_callback_path + '._get_value', return_value='N/A')
+        mocker.patch(logging_callback_path + '._update_attr')
+
+        self.logging_callback._update_metrics(
+            self.metric_list,
+            'target_prefix',
+            source_prefix='source_prefix',
+            logs='logs',
+            agent_id='agent_id',
+            reset=False
+        )
+
+        for args, _ in self.logging_callback._get_attr_name.call_args_list:
+            check.equal(args[0], 'target_prefix')
+            check.equal(args[2], 'agent_id')
+
+        for args, _ in self.logging_callback._get_value.call_args_list:
+            check.equal(args[1], 'source_prefix')
+            check.equal(args[2], 'agent_id')
+            check.equal(args[3], 'logs')
+
+        check.is_false(self.logging_callback._reset_attr.called)
+        check.is_false(self.logging_callback._update_attr.called)
+
+    def test_update(self, mocker):
+        """ should update attr if value is not N/A. """
+        logging_callback_path = 'learnrl.callbacks.logging_callback.LoggingCallback'
+        mocker.patch(logging_callback_path + '._get_attr_name', return_value="target_name")
+        mocker.patch(logging_callback_path + '._reset_attr')
+        mocker.patch(logging_callback_path + '._get_value', return_value="value")
+        mocker.patch(logging_callback_path + '._update_attr')
+
+        self.logging_callback._update_metrics(
+            self.metric_list,
+            'target_prefix',
+            source_prefix='source_prefix',
+            logs='logs',
+            agent_id='agent_id',
+            reset=False
+        )
+
+        for args, _ in self.logging_callback._get_attr_name.call_args_list:
+            check.equal(args[0], 'target_prefix')
+            check.equal(args[2], 'agent_id')
+
+        for args, _ in self.logging_callback._get_value.call_args_list:
+            check.equal(args[1], 'source_prefix')
+            check.equal(args[2], 'agent_id')
+            check.equal(args[3], 'logs')
+
+        check.is_false(self.logging_callback._reset_attr.called)
+
+        for args, _ in self.logging_callback._update_attr.call_args_list:
+            check.equal(args[0], 'target_name')
+            check.equal(args[1], 'value')
+
+    def test_reset(self, mocker):
+        """ should reset attr if reset is True. """
+        logging_callback_path = 'learnrl.callbacks.logging_callback.LoggingCallback'
+        mocker.patch(logging_callback_path + '._get_attr_name', return_value="target_name")
+        mocker.patch(logging_callback_path + '._reset_attr')
+        mocker.patch(logging_callback_path + '._get_value', return_value="value")
+        mocker.patch(logging_callback_path + '._update_attr')
+
+        self.logging_callback._update_metrics(
+            self.metric_list,
+            'target_prefix',
+            source_prefix='source_prefix',
+            logs='logs',
+            agent_id='agent_id',
+            reset=True
+        )
+
+        for args, _ in self.logging_callback._get_attr_name.call_args_list:
+            check.equal(args[0], 'target_prefix')
+            check.equal(args[2], 'agent_id')
+
+        for args, _ in self.logging_callback._get_value.call_args_list:
+            check.equal(args[1], 'source_prefix')
+            check.equal(args[2], 'agent_id')
+            check.equal(args[3], 'logs')
+
+        for args, _ in self.logging_callback._reset_attr.call_args_list:
+            check.equal(args[0], 'target_name')
+
+        check.is_false(self.logging_callback._update_attr.called)
+
+    def test_all_agents(self, mocker):
+        """ should update all agents indepentently. """
+        logging_callback_path = 'learnrl.callbacks.logging_callback.LoggingCallback'
+        mocker.patch(logging_callback_path + '._update_metrics')
+
+        n_agents = 5
+        self.logging_callback.n_agents = n_agents
+        self.logging_callback._update_metrics_all_agents(
+            self.metric_list,
+            'target_prefix'
+        )
+
+        for i in range(n_agents):
+            _, kwargs = self.logging_callback._update_metrics.call_args_list[i]
+            check.equal(kwargs.get('agent_id'), i)
diff --git a/tests/unit/callbacks/test_tensorboard.py b/tests/unit/callbacks/test_tensorboard.py
index d3381cc..a17de68 100644
--- a/tests/unit/callbacks/test_tensorboard.py
+++ b/tests/unit/callbacks/test_tensorboard.py
@@ -1,18 +1,27 @@
 # LearnRL a python library to learn and use reinforcement learning
 # Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
+# pylint: disable=unused-argument
 
+""" TensorboardCallback """
+
+import importlib.util
 import pytest
+
 import learnrl
-import importlib
+
 tensorflow_spec = importlib.util.find_spec('tensorflow')
 
 if tensorflow_spec is not None:
-    def test_instanciate_without_tensorflow(hide_tensorflow):
-        with pytest.raises(ImportError, match=r".*tensorflow >= 2.*"):
-            learnrl.callbacks.TensorboardCallback(log_dir='logs')
+    class TestTensorboardCallback():
+        """ TensorboardCallback """
 
-    def test_instanciate_with_tensorflow():
-        learnrl.callbacks.TensorboardCallback(log_dir='logs')
+        def test_instanciate_without_tensorflow(self, hide_tensorflow):
+            """ should raise an ImportError when tensorflow is not found. """
+            with pytest.raises(ImportError, match=r".*tensorflow >= 2.*"):
+                learnrl.callbacks.TensorboardCallback(log_dir='logs')
+
+        def test_instanciate_with_tensorflow(self):
+            """ should instanciate correctly when tensorflow is found. """
+            learnrl.callbacks.TensorboardCallback(log_dir='logs')
 else:
     pass
-
diff --git a/tests/unit/callbacks/test_wandb.py b/tests/unit/callbacks/test_wandb.py
index 92dc653..1d14ffd 100644
--- a/tests/unit/callbacks/test_wandb.py
+++ b/tests/unit/callbacks/test_wandb.py
@@ -1,17 +1,27 @@
 # LearnRL a python library to learn and use reinforcement learning
 # Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
+# pylint: disable=unused-argument
 
+""" Test WandbCallback """
+
+import importlib.util
 import pytest
+
 import learnrl
-import importlib
+
 wandb_spec = importlib.util.find_spec('wandb')
 
 if wandb_spec is not None:
-    def test_instanciate_without_wandb(hide_wandb):
-        with pytest.raises(ImportError, match=r".*wandb >= 0.10.*"):
-            learnrl.callbacks.WandbLogger()
+    class TestWandbCallback():
+        """ WandbCallback """
+
+        def test_instanciate_without_wandb(self, hide_wandb):
+            """ should raise an ImportError when wandb is not found. """
+            with pytest.raises(ImportError, match=r".*wandb >= 0.10.*"):
+                learnrl.callbacks.WandbCallback(run=None)
 
-    def test_instanciate_with_wandb():
-        learnrl.callbacks.WandbLogger()
+        def test_instanciate_with_wandb(self):
+            """ should instanciate correctly if wandb is found. """
+            learnrl.callbacks.WandbCallback(run=None)
 else:
     pass
diff --git a/tests/unit/estimators/__init__.py b/tests/unit/estimators/__init__.py
deleted file mode 100644
index 486f7aa..0000000
--- a/tests/unit/estimators/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
diff --git a/tests/unit/estimators/test_estimator.py b/tests/unit/estimators/test_estimator.py
deleted file mode 100644
index 3e61afd..0000000
--- a/tests/unit/estimators/test_estimator.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-import pytest
-import sys
-import importlib
-
-import learnrl
-from learnrl import Estimator
-from gym.spaces import Discrete
-
-class DummyEstimator(Estimator):
-
-    def build(self, **kwargs):
-        pass
-
-def test_update_learning_rate():
-
-    initial_learning_rate = 0.378
-    learning_rate_decay = 1e-1
-
-    observation_space = Discrete(4)
-    action_space = Discrete(3)
-
-    evaluation = DummyEstimator(observation_space, action_space, learning_rate=initial_learning_rate, learning_rate_decay=learning_rate_decay)
-    if evaluation.learning_rate != initial_learning_rate:
-        raise ValueError(f"Learning rate is {evaluation.learning_rate} instead of initial_learning rate {initial_learning_rate}")
-    
-    learning_rate = 1.0
-    evaluation.update_learning_rate(learning_rate=learning_rate)
-    if evaluation.learning_rate != learning_rate:
-        raise ValueError(f"Learning rate is {evaluation.learning_rate} instead of fixed updated learning rate {learning_rate}")
-    
-    evaluation.update_learning_rate()
-    expected_learning_rate = learning_rate*(1-learning_rate_decay)
-    if evaluation.learning_rate != expected_learning_rate:
-        raise ValueError(f"Learning rate is {evaluation.learning_rate} and did not decay to {expected_learning_rate}")
-
diff --git a/tests/unit/estimators/test_keras_estimator.py b/tests/unit/estimators/test_keras_estimator.py
deleted file mode 100644
index 1996316..0000000
--- a/tests/unit/estimators/test_keras_estimator.py
+++ /dev/null
@@ -1,33 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-import pytest
-from gym.spaces import Discrete
-
-import importlib
-tensorflow_spec = importlib.util.find_spec('tensorflow')
-
-if tensorflow_spec is not None:
-
-    def test_instanciate_keras_estimator_without_tensorflow(hide_tensorflow):
-        from learnrl.estimators import KerasEstimator
-
-        class DummyEstimator(KerasEstimator):
-            def build(self, **kwargs):
-                pass
-        
-        observation_space = Discrete(4)
-        action_space = Discrete(3)
-        with pytest.raises(ImportError, match=r".*tensorflow >= 2.*"):
-            DummyEstimator(observation_space, action_space)
-
-    def test_instanciate_keras_estimator_with_tensorflow():
-        from learnrl.estimators import KerasEstimator
-
-        class DummyEstimator(KerasEstimator):
-            def build(self, **kwargs):
-                pass
-        
-        observation_space = Discrete(4)
-        action_space = Discrete(3)
-        DummyEstimator(observation_space, action_space)
diff --git a/tests/unit/test_agent.py b/tests/unit/test_agent.py
new file mode 100644
index 0000000..e9b1d12
--- /dev/null
+++ b/tests/unit/test_agent.py
@@ -0,0 +1,38 @@
+# LearnRL a python library to learn and use reinforcement learning
+# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
+# pylint: disable=protected-access, attribute-defined-outside-init, unused-argument
+
+""" Test agent.py """
+
+import pytest
+import pytest_check as check
+
+from learnrl.agent import Agent
+
+class TestAgent:
+
+    """Agent"""
+
+    def test_act(self):
+        """ act should raise NotImplementedError. """
+        agent = Agent()
+        with pytest.raises(NotImplementedError):
+            agent.act('obs')
+
+    def test_learn(self):
+        """ learn should return an empty dictionary by default. """
+        agent = Agent()
+        logs = agent.learn()
+        check.equal(logs, {})
+
+    def test_remember(self):
+        """ remember should pass by default. """
+        agent = Agent()
+        agent.remember(
+            'observation',
+            'action',
+            'reward',
+            'done',
+            next_observation='next_observation',
+            info={'info': 'info'},
+        )
diff --git a/tests/unit/test_control.py b/tests/unit/test_control.py
deleted file mode 100644
index 31b6853..0000000
--- a/tests/unit/test_control.py
+++ /dev/null
@@ -1,136 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-import pytest
-import numpy as np
-from learnrl.control import Control, Greedy, Ucb, Puct
-from learnrl.estimators import TableEstimator
-from gym.spaces import Discrete
-
-@pytest.fixture
-def action_values():
-    action_values = TableEstimator(Discrete(2), Discrete(3))
-    action_values.load(np.array([[1, 0, -1], [-1, 1, 0]]))
-    return action_values
-
-@pytest.fixture
-def action_visits():
-    action_visits = TableEstimator(Discrete(2), Discrete(3))
-    action_visits.load(np.array([[100, 8, 3], [3, 100, 8]], dtype=np.uint8))
-    return action_visits
-
-@pytest.fixture
-def observations():
-    return np.array([0, 1, 0])
-
-# Test general control features
-def test_name():
-    with pytest.raises(ValueError):
-        Control()
-
-def test_update_exploration():
-    initial_exploration = 0.378
-    exploration_decay = 0.9
-    control = Control(name='default', exploration=initial_exploration, exploration_decay=exploration_decay)
-    if control.exploration != initial_exploration:
-        raise ValueError(f"Exploration is {control.exploration} instead of {initial_exploration}")
-    
-    exploration = 1.0
-    control.update_exploration(exploration=exploration)
-    if control.exploration != exploration:
-        raise ValueError(f"Exploration is {control.exploration} instead of fixed updated exploration {exploration}")
-    
-    control.update_exploration()
-    expected_exploration = exploration*(1-exploration_decay)
-    if control.exploration != expected_exploration:
-        raise ValueError(f"Exploration is {control.exploration} and did not decay to {expected_exploration}")
-
-def test_get_policy(action_values):
-    control = Control(name="dummy_control")
-    with pytest.raises(NotImplementedError):
-        control._get_policy(observations=[0], action_values=action_values)
-
-# Test classic controls
-
-def test_Greedy(action_values, action_visits, observations):
-    expected_policies = {}
-    expected_policies[0] = np.array([[1, 0, 0],
-                                     [0, 1, 0],
-                                     [1, 0, 0]])
-
-    expected_policies[0.5] = np.array([[0.5 + 0.5/3, 0.5/3, 0.5/3],
-                                       [0.5/3, 0.5 + 0.5/3, 0.5/3],
-                                       [0.5 + 0.5/3, 0.5/3, 0.5/3]])
-    
-    expected_policies[1] = np.array([[1/3, 1/3, 1/3],
-                                     [1/3, 1/3, 1/3],
-                                     [1/3, 1/3, 1/3]])
-
-    for eps in [0, 0.5, 1]:
-        greedy = Greedy(eps)
-        policy = greedy._get_policy(observations, action_values=action_values)
-        expected_policy = expected_policies[eps]
-        if not np.all(policy==expected_policy):
-            raise ValueError(f'Wrong policy for greedy with eps={eps}\
-                                \nExpected {expected_policy}\
-                                \nGot {policy}')
-
-def test_vectorized_Greedy(action_values, action_visits, observations):
-    expected_policies = np.array([[0.5 + 0.5/3, 0.5/3, 0.5/3],
-                                 [0.5/3, 0.5 + 0.5/3, 0.5/3]])
-
-    greedy = Greedy(0.5)
-    policy = greedy._get_policy(np.array([0, 1]), action_values=action_values)
-    expected_policy = expected_policies
-    if not np.all(policy==expected_policy):
-        raise ValueError(f'Wrong policy for greedy with eps={0.5}\
-                            \nExpected {expected_policy}\
-                            \nGot {policy}')
-
-def test_Ucb(action_values, action_visits, observations):
-    ucb = Ucb()
-    with pytest.raises(ValueError):
-        ucb._get_policy(observations, action_values=None, action_visits=None)
-
-    cs = [0, 2, 5]
-    expected_policies = {}
-    for i, c in enumerate(cs):
-        temp = np.zeros((3, 3))
-        temp[0, i] = 1
-        temp[1, (i+1)%3] = 1
-        temp[2, i] = 1
-        expected_policies[c] = temp
-
-    for c in cs:
-        ucb = Ucb(exploration=c)
-        policy = ucb._get_policy(observations, action_values=action_values, action_visits=action_visits)
-        expected_policy = expected_policies[c]
-        if not np.all(policy==expected_policy):
-            raise ValueError(f'Wrong policy for Ucb with c={c}\
-                                \nExpected {expected_policy}\
-                                \nGot {policy}')
-
-def test_Puct(action_values, action_visits, observations):
-    puct = Puct(10)
-
-    with pytest.raises(ValueError):
-        puct._get_policy(observations, action_values=None, action_visits=None)
-
-    cs = [0, 0.5, 1]
-    expected_policies = {}
-    for i, c in enumerate(cs):
-        temp = np.zeros((3, 3))
-        temp[0, i] = 1
-        temp[1, (i+1)%3] = 1
-        temp[2, i] = 1
-        expected_policies[c] = temp
-
-    for c in cs:
-        ucb = Puct(exploration=c)
-        policy = ucb._get_policy(observations, action_values=action_values, action_visits=action_visits)
-        expected_policy = expected_policies[c]
-        if not np.all(policy==expected_policy):
-            raise ValueError(f'Wrong policy for PUCT with c={c}\
-                                \nExpected {expected_policy}\
-                                \nGot {policy}')
-
diff --git a/tests/unit/test_envs.py b/tests/unit/test_envs.py
new file mode 100644
index 0000000..a00758c
--- /dev/null
+++ b/tests/unit/test_envs.py
@@ -0,0 +1,32 @@
+# LearnRL a python library to learn and use reinforcement learning
+# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
+# pylint: disable=protected-access, attribute-defined-outside-init, unused-argument
+
+""" Test agent.py """
+
+import pytest
+
+from learnrl.envs import TurnEnv
+
+
+class TestTurnEnv:
+
+    """ TurnEnv """
+
+    def test_step(self):
+        """ step should raise NotImplementedError. """
+        env = TurnEnv()
+        with pytest.raises(NotImplementedError):
+            env.step('action')
+
+    def test_turn(self):
+        """ turn should raise NotImplementedError. """
+        env = TurnEnv()
+        with pytest.raises(NotImplementedError):
+            env.turn('observation')
+
+    def test_reset(self):
+        """ reset should raise NotImplementedError. """
+        env = TurnEnv()
+        with pytest.raises(NotImplementedError):
+            env.reset()
diff --git a/tests/unit/test_evaluation.py b/tests/unit/test_evaluation.py
deleted file mode 100644
index a93ae15..0000000
--- a/tests/unit/test_evaluation.py
+++ /dev/null
@@ -1,35 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-import pytest
-import numpy as np
-
-from learnrl.evaluation import Evaluation, MonteCarlo, TemporalDifference
-from learnrl.memory import Memory
-
-# Test basic evaluation features
-def test_name():
-    with pytest.raises(ValueError):
-        Evaluation()
-
-def test_str():
-    name = 'default'
-    evaluation = Evaluation(name=name)
-    if str(evaluation) != name:
-        raise ValueError(f"str(Evaluation) should return {name} and not {str(evaluation)}")
-
-
-# # Test learning of classic evaluations
-# @pytest.fixture
-# def memory():
-#     memory = Memory()
-#     return memory
-
-# @pytest.fixture
-# def action_values():
-#     return np.array([[1, 0, -1], [-1, 1, 0]])
-
-# @pytest.fixture
-# def action_visits():
-#     return np.array([[100, 8, 3], [3, 100, 8]], dtype=np.uint16)
-
diff --git a/tests/unit/test_memory.py b/tests/unit/test_memory.py
deleted file mode 100644
index 3a98479..0000000
--- a/tests/unit/test_memory.py
+++ /dev/null
@@ -1,78 +0,0 @@
-# LearnRL a python library to learn and use reinforcement learning
-# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
-
-import pytest
-import numpy as np
-
-from copy import deepcopy
-from learnrl.memory import Memory
-
-
-class AgentMemoryError(Exception):
-    pass
-
-"""Test the behavior of the general memory for agents"""
-@pytest.fixture
-def memory():
-    """Create a memory instance"""
-    return Memory()
-
-def test_forget(memory):
-    """Test that we do reset memory"""
-    initial_datas = {key:None for key in memory.MEMORY_KEYS}
-
-    memory.remember(0, 0, 0, False, 0, info={'parameter':0})
-    if memory.datas == initial_datas:
-        raise AgentMemoryError('We are suppose to remember a dummy experience')
-    
-    memory.forget()
-    for key in initial_datas:
-        if memory.datas[key] is not None:
-            raise AgentMemoryError(f'We are suppose to have forgotten the dummy experience \
-                    \nBut in key {key}\
-                    \nWe still remember {memory.datas[key]}\
-                    \nInstead of {initial_datas[key]}')
-
-def test_remember(memory):
-    """
-    We ought to remember at least those MEMORY_KEYS : (observation, action, reward, done, next_observation, info)
-    For observation, action, next_observation :
-        Test that we can remember int, floats, lists, or numpy.ndarrays as numpy.ndarrays
-    For reward :
-        Test that we can remember int, floats as numpy.ndarrays
-    For done :
-        Test that we can remember bool as numpy.ndarrays
-    For info :
-        Test that we can remember dict as a 2D numpy.ndarrays
-    Test that for a same key in MEMORY_KEYS, datas have consitant shapes to create a numpy.ndarrays (except info)
-    """
-
-    # Test int and float behavior
-    memory.remember(observation=0, action=1.0, reward=2, done=False, next_observation=3, info={'param':4})
-    memory.remember(observation=5, action=6.0, reward=7, done=True, next_observation=8, info={'param':9})
-    expected = {'observation':np.array([0, 5]), 'action':np.array([1.0, 6.0]), 'reward':np.array([2, 7]),
-                'done':np.array([0, 1]), 'next_observation':np.array([3, 8]), 'info':np.array([{'param':4}, {'param':9}])}
-    for key in memory.MEMORY_KEYS:
-        if not np.all(memory.datas[key]==expected[key]):
-            raise AgentMemoryError(f'Couldn\'t remember the int&float dummy exemple !\
-                        \nKey : {key}\nGot \n{memory.datas[key]}\ninstead of\n{expected[key]}')
-    memory.forget()
-
-    # Test list behavior
-    memory.remember(observation=[0, 1], action=[2, 3], reward=4, done=False, next_observation=[5, 6], info={'param':7})
-    memory.remember(observation=[8, 9], action=[10, 11], reward=12, done=True, next_observation=[13, 14], info={'param':15})
-    expected = {'observation':np.array([[0, 1], [8, 9]]), 'action':np.array([[2, 3], [10, 11]]), 'reward':np.array([4, 12]),
-                'done':np.array([0, 1]), 'next_observation':np.array([[5, 6], [13, 14]]), 'info':np.array([{'param':7}, {'param':15}])}
-    for key in memory.MEMORY_KEYS:
-        if not np.all(memory.datas[key]==expected[key]):
-            raise AgentMemoryError(f'Couldn\'t remember the list dummy exemple !\
-                        \n Key : {key}, Got {memory.datas[key]} instead of {expected[key]}')
-    memory.forget()
-
-    # Test shape consistensy forced by numpy.ndarrays
-    with pytest.raises(ValueError):
-        memory.remember(observation=0, action=1.0, reward=2, done=False, next_observation=3, info={'param':4})
-        memory.remember(observation=[0, 1], action=[2, 3], reward=4, done=False, next_observation=[5, 6], info={'param':7})
-    
-# def test_sample(memory):
-#     raise NotImplementedError
diff --git a/tests/unit/test_playground.py b/tests/unit/test_playground.py
new file mode 100644
index 0000000..f628455
--- /dev/null
+++ b/tests/unit/test_playground.py
@@ -0,0 +1,693 @@
+# LearnRL a python library to learn and use reinforcement learning
+# Copyright (C) 2020 Mathïs FEDERICO <https://www.gnu.org/licenses/>
+# pylint: disable=protected-access, attribute-defined-outside-init, unused-argument, too-many-instance-attributes
+
+""" Test playground.py """
+
+import pytest
+import pytest_check as check
+
+import numpy as np
+
+from gym import Env
+from learnrl.agent import Agent
+from learnrl.envs import TurnEnv
+from learnrl.playground import Playground, RewardHandler, DoneHandler
+from learnrl.callbacks.callback import CallbackList, Callback
+
+class TestPlayground:
+
+    """ Playground """
+
+    @pytest.fixture(autouse=True)
+    def setup_playground(self):
+        """Setup of used fixtures"""
+        self.env = Env()
+        self.n_agents = 5
+        self.agents = [Agent() for _ in range(self.n_agents)]
+
+    def test_single_agent_argument(self):
+        " should transform a single agent in a list containing itself. "
+        single_agent = Agent()
+        playground = Playground(self.env, single_agent)
+        check.equal(playground.agents, [single_agent])
+
+    def test_env_typeerror(self):
+        " should raise a TypeError if the environment isn't a subclass of gym. "
+        with pytest.raises(TypeError, match=r"environement.*gym.Env"):
+            Playground("env", self.agents)
+
+    def test_agent_typeerror(self):
+        " should raise a TypeError if any agent isn't a subclass of learnrl.Agent. "
+        with pytest.raises(TypeError, match=r"agent.*learnrl.Agent"):
+            Playground(self.env, [Agent(), 'agent'])
+
+    def test_run(self, mocker):
+        """ run should call callbacks at the right time and in the right order. """
+
+        steps_outputs = [
+            (f'obs_{i+1}', False)
+            for i in range(9)
+        ]
+        steps_outputs += [(f'obs_{9}', True)]
+        steps_outputs = steps_outputs*10
+        steps_outputs = steps_outputs[::-1]
+
+        def dummy_run_step(*args, **kwargs):
+            return steps_outputs.pop()
+
+        class RegisterCallback(Callback):
+
+            """Dummy Callback to register calls"""
+
+            def __init__(self):
+                super().__init__()
+                self.stored_key = ""
+
+            def on_run_begin(self, logs=None):
+                self.stored_key += "|-"
+
+            def on_episodes_cycle_begin(self, episode, logs=None):
+                self.stored_key += "["
+
+            def on_episode_begin(self, episode, logs=None):
+                self.stored_key += "("
+
+            def on_steps_cycle_begin(self, step, logs=None):
+                self.stored_key += "<"
+
+            def on_step_begin(self, step, logs=None):
+                self.stored_key += ","
+
+            def on_step_end(self, step, logs=None):
+                self.stored_key += "."
+
+            def on_steps_cycle_end(self, step, logs=None):
+                self.stored_key += ">"
+
+            def on_episode_end(self, episode, logs=None):
+                self.stored_key += ")"
+
+            def on_episodes_cycle_end(self, episode, logs=None):
+                self.stored_key += "]"
+
+            def on_run_end(self, logs=None):
+                self.stored_key += "-|"
+
+        mocker.patch(
+            'learnrl.playground.Playground._get_episodes_cycle_len',
+            return_value=3
+        )
+        mocker.patch(
+            'learnrl.playground.Playground._reset',
+            lambda *args: ('obs_0', 0, False, {})
+        )
+        mocker.patch(
+            'learnrl.playground.Playground._build_callbacks',
+            lambda self, callbacks, logger, params: callbacks[0]
+        )
+        mocker.patch(
+            'learnrl.playground.Playground._run_step',
+            dummy_run_step
+        )
+
+        playground = Playground(self.env, self.agents)
+        register_callback = RegisterCallback()
+        playground.run(episodes=10, callbacks=[register_callback], steps_cycle_len=3)
+        episode_key = "(<,.,.,.><,.,.,.><,.,.,.><,.>)"
+        expected_key = "|-[" + episode_key*3 + "][" + episode_key*3 + "][" + \
+            episode_key*3  + "][" + episode_key + "]-|"
+        check.equal(register_callback.stored_key, expected_key)
+
+    def test_fit(self, mocker):
+        """ fit should call run with learn=True and render=False. """
+        mocker.patch('learnrl.playground.Playground.run')
+        playground = Playground(self.env, self.agents)
+        playground.fit(10)
+        _, kwargs = playground.run.call_args
+        check.is_true(kwargs.get('learn'))
+        check.is_false(kwargs.get('render'))
+
+    def test_fit_warn_learn(self, mocker):
+        """ fit should warn a UserWarning if learn=False. """
+        mocker.patch('learnrl.playground.Playground.run')
+        playground = Playground(self.env, self.agents)
+        with pytest.warns(UserWarning, match=r".*agents will not improve.*"):
+            playground.fit(10, learn=False)
+
+    def test_fit_warn_render(self, mocker):
+        """ fit should warn a RuntimeWarning if render=True. """
+        mocker.patch('learnrl.playground.Playground.run')
+        playground = Playground(self.env, self.agents)
+        with pytest.warns(RuntimeWarning, match=r".*computation speed.*"):
+            playground.fit(10, render=True)
+
+    def test_test(self, mocker):
+        """ test should call run with learn=False and render=True. """
+        mocker.patch('learnrl.playground.Playground.run')
+        playground = Playground(self.env, self.agents)
+        playground.test(10)
+        _, kwargs = playground.run.call_args
+        check.is_false(kwargs.get('learn'))
+        check.is_true(kwargs.get('render'))
+
+    def test_test_warn_learn(self, mocker):
+        """ test should warn a UserWarning if learn=True. """
+        mocker.patch('learnrl.playground.Playground.run')
+        playground = Playground(self.env, self.agents)
+        with pytest.warns(UserWarning, match=r".*not act greedy.*"):
+            playground.test(10, learn=True)
+
+    def test_test_warn_render(self, mocker):
+        """ test should warn a UserWarning if render=False. """
+        mocker.patch('learnrl.playground.Playground.run')
+        playground = Playground(self.env, self.agents)
+        with pytest.warns(UserWarning, match=r".*render=True.*"):
+            playground.test(10, render=False, verbose=0)
+
+class TestPlaygroundGetEpisodeCycleLen:
+
+    """ Playground._get_episodes_cycle_len """
+
+    @pytest.fixture(autouse=True)
+    def setup(self):
+        """Setup of used fixtures"""
+        self.get_episodes_cycle_len = Playground._get_episodes_cycle_len
+
+    def test_over_one(self):
+        " should be the floor if > 1. "
+        value = self.get_episodes_cycle_len(10, 100)
+        check.equal(value, 10)
+
+        value = self.get_episodes_cycle_len(12.4, 100)
+        check.equal(value, 12)
+
+    def test_between_zero_one(self):
+        " should be a proportion of total episodes if between 0 and 1. "
+        value = self.get_episodes_cycle_len(0.42, 100)
+        check.equal(value, 42)
+
+    def test_bellow_zero(self):
+        " should raise a ValueError if < 0. "
+        with pytest.raises(ValueError, match=r".*> 0.*"):
+            self.get_episodes_cycle_len(0, 100)
+
+        with pytest.raises(ValueError, match=r".*> 0.*"):
+            self.get_episodes_cycle_len(-0.2, 100)
+
+
+class TestPlaygroundBuildCallbacks:
+    """ Playground._build_callbacks """
+
+    @pytest.fixture(autouse=True)
+    def setup_playground(self):
+        """Setup of used fixtures"""
+        self.env = Env()
+        self.n_agents = 5
+        self.agents = [Agent() for _ in range(self.n_agents)]
+        self.playground = Playground(self.env, self.agents)
+
+    def test_build_callback(self, mocker):
+        """ should build a CallbackList from given callbacks and logger
+        and set their params and playground. """
+        callback_path = 'learnrl.callbacks.callback.Callback'
+        mocker.patch(callback_path + '.set_params')
+        mocker.patch(callback_path + '.set_playground')
+
+        callbacks = [Callback(), Callback()]
+        logger = Callback()
+        params = {'param123': 123}
+        callbacklist = self.playground._build_callbacks(callbacks, logger, params)
+        check.is_instance(callbacklist, CallbackList)
+        for callback in callbacklist.callbacks:
+            args, _ = callback.set_params.call_args
+            check.equal(args[0], params)
+            args, _ = callback.set_playground.call_args
+            check.equal(args[0], self.playground)
+        check.is_in(logger, callbacklist.callbacks)
+
+    def test_builb_callback_no_logger(self):
+        """ should work if logger is None. """
+        callbacks = [Callback(), Callback()]
+        logger = None
+        params = {'param123': 123}
+        callbacklist = self.playground._build_callbacks(callbacks, logger, params)
+        check.is_instance(callbacklist, CallbackList)
+        for callback in callbacklist.callbacks:
+            check.equal(callback.params, params)
+            check.equal(callback.playground, self.playground)
+
+
+class TestPlaygroundReset:
+
+    """ Playground._reset """
+
+    @pytest.fixture(autouse=True)
+    def setup_playground(self):
+        """Setup of used fixtures"""
+        self.env = Env()
+        self.n_agents = 5
+        self.agents = [Agent() for _ in range(self.n_agents)]
+        self.playground = Playground(self.env, self.agents)
+
+    def test_no_handlers(self, mocker):
+        """ should reset the environment with no handlers. """
+
+        mocker.patch('gym.Env.reset', lambda self:'obs')
+
+        observation, step, done, previous = self.playground._reset(None, None)
+        check.equal(observation, 'obs')
+        check.equal(step, 0)
+        check.equal(done, False)
+        expected_previous = [
+            {'observation':None, 'action':None, 'reward':None, 'done':None, 'info':None}
+            for _ in range(len(self.agents))
+        ]
+        check.equal(previous, expected_previous)
+
+    def test_callable_handlers(self, mocker):
+        """ should reset the environment with callable handlers. """
+
+        def done_handler(**kwargs):
+            return not kwargs.get('done')
+
+        def reward_handler(**kwargs):
+            return 2 * kwargs.get('reward')
+
+        mocker.patch('gym.Env.reset', lambda self:'obs')
+
+        observation, step, done, previous = self.playground._reset(reward_handler, done_handler)
+        check.equal(observation, 'obs')
+        check.equal(step, 0)
+        check.equal(done, False)
+        expected_previous = [
+            {'observation':None, 'action':None, 'reward':None, 'done':None, 'info':None}
+            for _ in range(len(self.agents))
+        ]
+        check.equal(previous, expected_previous)
+
+    def test_with_handlers(self, mocker):
+        """ should reset the environment and handlers with true handlers. """
+
+        mocker.patch('gym.Env.reset', lambda self:'obs')
+
+        mocker.patch('learnrl.playground.RewardHandler.reset')
+        reward_handler = RewardHandler()
+
+        mocker.patch('learnrl.playground.DoneHandler.reset')
+        done_handler = DoneHandler()
+
+        observation, step, done, previous = self.playground._reset(reward_handler, done_handler)
+
+        check.equal(observation, 'obs')
+        check.equal(step, 0)
+        check.equal(done, False)
+        expected_previous = [
+            {'observation':None, 'action':None, 'reward':None, 'done':None, 'info':None}
+            for _ in range(len(self.agents))
+        ]
+        check.equal(previous, expected_previous)
+
+        check.is_true(reward_handler.reset.called)
+        check.is_true(done_handler.reset.called)
+
+
+class TestPlaygroundAgentOrder:
+
+    """ Playground.set_agent_order """
+
+    @pytest.fixture(autouse=True)
+    def setup_playground(self):
+        """Setup of used fixtures"""
+        self.env = Env()
+        self.n_agents = 5
+        self.agents = [Agent() for _ in range(self.n_agents)]
+        self.playground = Playground(self.env, self.agents)
+
+    def test_default(self):
+        """ should have a correct default agent order. """
+        check.equal(self.playground.agents_order, list(range(self.n_agents)),
+            f"Default agents_order shoud be {list(range(self.n_agents))}"
+            f"but was {self.playground.agents_order}"
+        )
+
+    def test_custom_at_init(self):
+        """ should be able to have custom order at initialization. """
+        custom_order = [4, 3, 1, 2, 0]
+        playground = Playground(self.env, self.agents, agents_order=custom_order)
+        check.equal(playground.agents_order, custom_order,
+            f"Custom agents_order shoud be {custom_order}"
+            f"but was {playground.agents_order}"
+        )
+
+    def test_custom_after_init(self):
+        """ should be able to set custom order after initialization. """
+        new_custom_order = [3, 4, 1, 2, 0]
+        self.playground.set_agents_order(new_custom_order)
+
+        check.equal(self.playground.agents_order, new_custom_order,
+            f"Custom agents_order shoud be {new_custom_order}"
+            f"but was {self.playground.agents_order}"
+        )
+
+    def test_not_enought_indexes(self):
+        """ should raise ValueError if not enough indexes in custom order. """
+        with pytest.raises(ValueError, match=r"Not every agents*"):
+            Playground(self.env, self.agents, agents_order=[4, 3, 1, 2])
+
+    def test_missing_indexes(self):
+        """ should raise ValueError if missing indexes in custom order. """
+        with pytest.raises(ValueError, match=r".*not taking every index*"):
+            Playground(self.env, self.agents, agents_order=[4, 6, 1, 2, 0])
+
+
+class TestPlaygroundGetNextAgent:
+    """ Playground._get_next_agent """
+
+    @pytest.fixture(autouse=True)
+    def setup_playground(self):
+        """Setup of used fixtures"""
+        self.n_agents = 5
+        self.agents = [Agent() for _ in range(self.n_agents)]
+
+    def test_no_turnenv(self):
+        """ should return the first agent if env is not a TurnEnv. """
+        playground = Playground(Env(), self.agents)
+        _, agent_id = playground._get_next_agent('observation')
+        check.equal(agent_id, 0)
+
+    def test_turnenv(self, mocker):
+        """ should return the agent designed by agent_order and turn if env is a TurnEnv. """
+        mocker.patch('learnrl.envs.TurnEnv.turn', return_value=2)
+        playground = Playground(TurnEnv(), self.agents)
+        playground.agents_order = [3, 2, 1, 0, 4]
+        _, agent_id = playground._get_next_agent('observation')
+        check.equal(agent_id, 1)
+
+    def test_turnenv_indexerror(self, mocker):
+        """ should raise ValueError if turn result is out of agent_order. """
+        mocker.patch('learnrl.envs.TurnEnv.turn', return_value=10)
+        playground = Playground(TurnEnv(), self.agents)
+        with pytest.raises(ValueError, match=r'Not enough agents.*'):
+            playground._get_next_agent('observation')
+
+
+class TestPlaygroundCallHandlers:
+    """Playground._call_handlers"""
+
+    @pytest.fixture(autouse=True)
+    def setup(self):
+        """Setup of used fixtures"""
+        self.call_handlers = Playground._call_handlers
+        self.done = False
+        self.reward = 1.2
+        self.experience = {
+            'observation': 'observation',
+            'action': 'action',
+            'reward': self.reward,
+            'done': self.done,
+            'next_observation': 'next_observation',
+            'info': {'info': 'info'}
+        }
+
+
+    def test_no_handlers(self):
+        """ should not log or change experience if no handler is given. """
+        logs = {}
+        self.call_handlers(
+            self.reward,
+            self.done,
+            self.experience,
+            reward_handler=None,
+            done_handler=None,
+            logs=logs
+        )
+        check.equal(self.experience['done'], self.done)
+        check.equal(logs['done'], self.done)
+
+        check.equal(self.experience['reward'], self.reward)
+        check.equal(logs['reward'], self.reward)
+
+    def test_done_only(self):
+        """ should log and change experience correctly with a done_handler only. """
+        logs = {}
+        handled_done = not self.done
+        self.call_handlers(
+            self.reward,
+            self.done,
+            self.experience,
+            reward_handler=None,
+            done_handler=lambda *args: handled_done,
+            logs=logs
+        )
+        check.equal(self.experience['done'], handled_done)
+        check.equal(logs['done'], self.done)
+        check.equal(logs['handled_done'], handled_done)
+
+        check.equal(self.experience['reward'], self.reward)
+        check.equal(logs['reward'], self.reward)
+
+    def test_reward_only(self):
+        """ should log and change experience correctly with a reward_handler only. """
+        logs = {}
+        handled_reward = 1.7
+        self.call_handlers(
+            self.reward,
+            self.done,
+            self.experience,
+            reward_handler=lambda *args:handled_reward,
+            done_handler=None,
+            logs=logs
+        )
+        check.equal(self.experience['done'], self.done)
+        check.equal(logs['done'], self.done)
+
+        check.equal(self.experience['reward'], handled_reward)
+        check.equal(logs['reward'], self.reward)
+        check.equal(logs['handled_reward'], handled_reward)
+
+    def test_both_handlers(self):
+        """ should log and change experience correctly with a both handlers. """
+        logs = {}
+        handled_done = not self.done
+        handled_reward = 1.7
+        self.call_handlers(
+            self.reward,
+            self.done,
+            self.experience,
+            reward_handler=lambda *args: handled_reward,
+            done_handler=lambda *args: handled_done,
+            logs=logs
+        )
+        check.equal(self.experience['done'], handled_done)
+        check.equal(logs['done'], self.done)
+        check.equal(logs['handled_done'], handled_done)
+
+        check.equal(self.experience['reward'], handled_reward)
+        check.equal(logs['reward'], self.reward)
+        check.equal(logs['handled_reward'], handled_reward)
+
+class TestPlaygroundRunStep:
+    """Playground._run_step"""
+
+    @pytest.fixture(autouse=True)
+    def setup_playground(self, mocker):
+        """Setup of used fixtures"""
+
+        self.observation = 'observation'
+        self.next_observation = 'next_observation'
+        self.reward = 1.2
+        self.handled_reward = 1.7
+        self.done = False
+        self.info = {'env_info': 'env_info'}
+
+        mocker.patch('gym.Env.render')
+        mocker.patch('gym.Env.step',
+            return_value=(self.next_observation, self.reward,
+                self.done, self.info)
+        )
+        self.env = Env()
+
+        mocker.patch('learnrl.agent.Agent.remember')
+        mocker.patch('learnrl.agent.Agent.learn')
+        self.action = 3
+        mocker.patch('learnrl.agent.Agent.act', return_value=self.action)
+        self.n_agents = 5
+        self.agents = [Agent() for _ in range(self.n_agents)]
+
+        self.agent_id = 0
+        mocker.patch('learnrl.playground.Playground._get_next_agent',
+            return_value=(self.agents[self.agent_id], self.agent_id))
+
+        def handler_mocker(cls, reward, done, experience, reward_handler, done_handler, logs):
+            experience['reward'] = self.handled_reward
+            logs['handled_reward'] = self.handled_reward
+
+        mocker.patch('learnrl.playground.Playground._call_handlers', handler_mocker)
+        self.playground = Playground(self.env, self.agents)
+
+        self.previous = [
+            {'observation':None,'action':None,
+            'reward':None, 'done':None, 'info':None}
+            for _ in range(self.n_agents)
+        ]
+
+    def test_run_step(self):
+        """ should update the observation, done and logs correcty. """
+        logs = {}
+        done = not self.done
+        observation, done = self.playground._run_step(
+            self.observation,
+            self.previous,
+            logs=logs,
+        )
+        check.equal(observation, self.next_observation)
+        check.equal(done, self.done)
+
+        for log_name in ['reward', 'handled_reward', 'observation',
+                            'next_observation', 'info', 'done', 'agent_id', 'action']:
+            expected = getattr(self, log_name)
+            check.equal(logs[log_name], expected)
+
+    def test_render_not_done(self, mocker):
+        """ should render at each step beginning if not done. """
+        render_mode = 'render_mode'
+        self.playground._run_step(
+            self.observation,
+            self.previous,
+            logs={},
+            render=True,
+            render_mode=render_mode
+        )
+
+        check.equal(len(self.env.render.call_args_list), 1)
+        render_args, _ = self.env.render.call_args
+        check.equal(render_args[0], render_mode)
+
+    def test_render_done(self, mocker):
+        """ should render at step beginning and end if done. """
+        render_mode = 'render_mode'
+        self.env.step.return_value = (
+            self.next_observation, self.reward, True, self.info
+        )
+        self.playground._run_step(
+            self.observation,
+            self.previous,
+            logs={},
+            render=True,
+            render_mode=render_mode
+        )
+
+        check.equal(len(self.env.render.call_args_list), 2)
+        render_args, _ = self.env.render.call_args_list[-1]
+        check.equal(render_args[0], render_mode)
+
+    def test_learn_without_prev_not_done(self, mocker):
+        """ should store experience without learn or remember for first experience. """
+        previous = self.previous
+        self.playground._run_step(
+            self.observation,
+            previous,
+            logs={},
+            learn=True,
+        )
+        check.equal(previous[self.agent_id],
+            {name: getattr(self, name)
+            for name in ['observation', 'action', 'reward', 'done', 'info']}
+        )
+        check.is_false(Agent.learn.called)
+        check.is_false(Agent.remember.called)
+
+    def test_learn_not_done(self, mocker):
+        """ should call learn and remember once at the beginning of the step if not done. """
+        logs = {}
+        previous = self.previous
+        previous[0] = {
+            name: getattr(self, name)
+            for name in ['observation', 'action', 'reward', 'done', 'info']
+        }
+        self.playground._run_step(
+            self.observation,
+            previous,
+            logs=logs,
+            learn=True,
+        )
+
+        check.equal(len(Agent.learn.call_args_list), 1)
+        check.equal(len(Agent.remember.call_args_list), 1)
+        check.is_in('agent_0', logs)
+
+    def test_learn_done(self, mocker):
+        """ should call learn and remember once at the beginning of the step if not done. """
+        logs = {}
+        previous = self.previous
+        self.env.step.return_value = (
+            self.next_observation, self.reward, True, self.info
+        )
+        self.playground._run_step(
+            self.observation,
+            previous,
+            logs=logs,
+            learn=True,
+        )
+
+        check.equal(len(Agent.learn.call_args_list), 1)
+        check.equal(len(Agent.remember.call_args_list), 1)
+        check.is_in('agent_0', logs)
+
+class TestDoneHandler:
+
+    """DoneHandler"""
+
+    def test_done(self, mocker):
+        """ should use `done` if the output of `done` is a bool or a bool numpy array. """
+        mocker.patch("learnrl.playground.DoneHandler.done", lambda *args: True)
+        handler = DoneHandler()
+        check.is_true(handler._done())
+
+        mocker.patch("learnrl.playground.DoneHandler.done", lambda *args: np.array(True))
+        handler = DoneHandler()
+        check.is_true(handler._done())
+
+    def test_not_bool_done(self, mocker):
+        """ should raise ValueError if the output of `done` is not a bool. """
+        mocker.patch("learnrl.playground.DoneHandler.done", lambda *args: 'True')
+        handler = DoneHandler()
+        with pytest.raises(ValueError, match=r"Done should be bool.*"):
+            handler._done()
+
+    def test_call(self, mocker):
+        """ should call done on class call. """
+        mocker.patch("learnrl.playground.DoneHandler._done", return_value=True)
+        handler = DoneHandler()
+        check.is_true(handler())
+        check.is_true(handler._done.called)
+
+
+class TestRewardHandler:
+
+    """RewardHandler"""
+
+    def test_reward(self, mocker):
+        """ should use `reward` if the output of `reward` is a float or floating numpy array. """
+        mocker.patch("learnrl.playground.RewardHandler.reward", lambda *args: 1.2)
+        handler = RewardHandler()
+        check.equal(handler._reward(), 1.2)
+
+        mocker.patch("learnrl.playground.RewardHandler.reward", lambda *args: np.array(1.2))
+        handler = RewardHandler()
+        check.equal(handler._reward(), 1.2)
+
+    def test_not_scalar_reward(self, mocker):
+        """ should raise ValueError if the output of `reward` is not a float. """
+        mocker.patch("learnrl.playground.RewardHandler.reward", lambda *args: '1.2')
+        handler = RewardHandler()
+        with pytest.raises(ValueError, match=r"Rewards should be a float.*"):
+            handler._reward()
+
+    def test_call(self, mocker):
+        """ should call reward on class call. """
+        mocker.patch("learnrl.playground.RewardHandler._reward", return_value=True)
+        handler = RewardHandler()
+        check.is_true(handler())
+        check.is_true(handler._reward.called)