diff --git a/docs/src/index.md b/docs/src/index.md
index 2c1f722ed4f9a1d056b0e6a89854dd5ecb400dea..19f8c7572ba30d5f52b30730205f17b8b2c741a1 100644
--- a/docs/src/index.md
+++ b/docs/src/index.md
@@ -40,4 +40,4 @@ optional arguments:
 
 *TODO: describe config & map files*
 
-*Last updated: 2023-02-02 (commit 577ff86)*  
+*Last updated: 2023-02-02 (commit 6bcd56b)*  
diff --git a/docs/src/species-dsl.md b/docs/src/species-dsl.md
index bedb5d8551a630a0aa418ccc7d3b70e4fe5ef5a2..6efd1d0e8ca10639432a4147c1816fd9a2ba9aa9 100644
--- a/docs/src/species-dsl.md
+++ b/docs/src/species-dsl.md
@@ -1,10 +1,104 @@
 # Defining new species
 
-*TODO*
-
 ## The Persephone species DSL
 
+In order to make implementing new species as easy as possible, Persephone includes
+a domain-specific language (DSL) built from a collection of macros and functions.
+
+Here is an example (using a hypothetical mermaid species) of what this looks like:
+
+```julia
+@species Mermaid begin
+    ageofmaturity = 2
+    pesticidemortality = 1.0
+    @initialise(@habitat(@landcover() == water), pairs=true)
+	
+    @phase life begin
+        @debug "$(animalid(animal)) is swimming happily in its pond."
+        @respond pesticide @kill(@trait(pesticidemortality), "poisoning")
+        @respond harvest @setphase(drought)
+        @debug "Animal: $animal"
+        if @trait(sex) == female && @countanimals() < 3 &&
+            @trait(age) >= @trait(ageofmaturity) && @landcover() == water
+            @reproduce()
+        end
+    end
+	
+    @phase drought begin
+        n = sum(1 for a in @neighbours(0))
+        @debug "$(animalid(animal)) is experiencing drought with $n neighbour(s)."
+        @respond sowing @setphase(life)
+    end
+end
+```
+
+The two most important macros are `@species` and `@phase`, followed `@initialise`,
+`@trait`, `@respond`, and `@habitat`. Other macros provide convenience wrappers
+for common functions. (See `src/nature/nature.jl` for details.)
+
+The top-level macro is `@species`. This takes two arguments: a species name and
+a definition block (enclosed in `begin` and `end` tags). At the start of the
+definition block, species-specific variables can be defined that should be available
+throughout a species' lifetime. Code in this section has access to the `model`
+object and can thus reference the current model state. In this section, the user
+also has to call the `@initialise` macro. This wraps the `initpopulation()`
+function, and takes a habitat descriptor (see `@habitat` below) and several options
+to specify how the species' population should be distributed in the landscape
+during model initialisation.
+
+Following this section, each species must define one or more `@phase` blocks.
+The concept behind this is that species show different behaviours at different
+phases of their lifecycle. Each `@phase` block defines the behaviour in one of
+these phases. (Technically, it defines a function that will be called daily, so
+long as the species' `phase` variable is set to the name of this phase.) Code
+in this section has access to the `model` object as well as an `animal` object,
+which is the currently active animal agent. Properties of the `animal` agent,
+regardless of whether they were defined by the user or by Persephone, can be
+accessed using the `@trait` macro. Within a phase block, `@respond` can be used
+to define the species' response to a `FarmEvent` that affects the species'
+current location, while a variety of other macros provide wrappers to ecological
+process functions from `src/nature/populations.jl`.
 
+Another important macro is `@habitat`. This defines a "habitat descriptor",
+i.e. a predicate function that tests whether or not a given landscape pixel is 
+suitable for a specified purpose. Such habitat descriptors are used as arguments
+to various functions, for example for population initialisation or movement.
+The argument to `@habitat` consists of a logical expression, which has access
+to the animal's current position (the `pos` tuple variable) and the `model`.
+Various macros are available to easily reference information about the current
+location, such as `@landcover` or `@distancetoedge`.
 
 ## Implementation details
 
+Due to a known [performance problem](https://juliadynamics.github.io/Agents.jl/stable/performance_tips/#Avoid-Unions-of-many-different-agent-types-(temporary!)-1) 
+with multi-agent models, the underlying implementation of species is 
+rather complicated (see `src/nature/nature.jl` for details.)
+
+Rather than creating a new type/struct for each species, all animal agents
+have the same type. Instead, they are differentiated by a `traits` dict,
+which stores both species-specific parameters and run-time variables.
+Note that due to a redefinition of the `getproperty()/setproperty!()`
+methods, variables from the trait dict can be accessed and modified just
+like normal struct fields (i.e. although `phase` is defined in the dict,
+not the struct, `animal.phase = "newphase"` works just fine - one does
+not have to use `animal.traits["phase"] = "newphase"`.)
+
+Under the hood, the `@species` macro generates a function (with the name
+of the species), which in turn creates the trait dict when called. Thus,
+adding a new animal agent to the model involves instantiating an `Animal`
+object, then calling the relevant species function and attaching the returned
+dict to the agent object.
+
+Similarly, the `@phase` macro too works by defining a new function, which
+is stored in the species' trait dict. These functions take an animal object
+and the model object as input, and define what the species does during its
+daily update.
+
+Once again, `@habitat` creates a function that takes `model` and `pos`
+as input and returns a boolean response. Functions that require a habitat
+descriptor thus take in this (anonymous) function and call it internally.
+
+Finally, the `@initialise` macro is a wrapper around `initpopulation()`,
+which (yet again) creates a function that specifies how a species' population
+is to be initialised at the beginning of a simulation run. This function is
+stored in the species trait dict and accessed during model setup.