diff --git a/src/Persefone.jl b/src/Persefone.jl
index e256782393a29abb05b29b8d26529f4528a3a802..aae3d8d88a8f49c65d5170128eae1d9bc75388e0 100644
--- a/src/Persefone.jl
+++ b/src/Persefone.jl
@@ -23,7 +23,7 @@ using
     Distributed,
     FileIO,
     #FIXME an upstream update broke GeoArrays for TableTransforms > 1.15.0
-    GeoArrays,
+    GeoArrays, #XXX this is another big dependency
     Logging,
     LoggingExtras,
     Random,
@@ -129,7 +129,7 @@ include("nature/ecologicaldata.jl")
 # end
 #include("nature/species/skylark.jl")
 include("nature/species/wolpertinger.jl")
-include("nature/species/wyvern.jl")
+#include("nature/species/wyvern.jl")
 
 include("core/simulation.jl") #this must be last
 
diff --git a/src/core/output.jl b/src/core/output.jl
index e4bdf00a007ef23e7aaffae532abaa5ad12673d1..6d1fc2d533ec47269c8dbd82403d51d63759f84b 100644
--- a/src/core/output.jl
+++ b/src/core/output.jl
@@ -80,7 +80,7 @@ function saveinputfiles(model::SimulationModel)
         println(f, "# Simulation run on $(string(Dates.format(Dates.now(), "d u Y HH:MM:SS"))),")
         # Record the current git commit and versions of dependencies for reproducibility
         println(f, "# with Persefone $(pkgversion(Persefone)), git commit $(currentcommit),")
-        println(f, "# running on Julia $(VERSION) with Agents.jl $(pkgversion(Agents)).\n#\n")
+        println(f, "# running on Julia $(VERSION).\n#\n")
         if !isempty(strip(read(`git status -s`, String)))
             println(f, "# WARNING: Your repository contains uncommitted changes. This may")
             println(f, "#          compromise the reproducibility of this simulation run.\n")
diff --git a/src/core/simulation.jl b/src/core/simulation.jl
index d139fdf37b9e1553a63b4787dcd6f7fbc23d7cdb..d6552a6d75cea2729f816dd99da2f9f655287752 100644
--- a/src/core/simulation.jl
+++ b/src/core/simulation.jl
@@ -24,7 +24,7 @@ mutable struct AgricultureModel <: SimulationModel
     crops::Dict{String,CropType}
     farmers::Vector{Farmer}
     farmplots::Vector{FarmPlot}
-    animals::Vector{Union{Animals,Nothing}}
+    animals::Vector{Union{Animal,Nothing}}
     migrants::Vector{Pair{Animal,Date}}
     events::Vector{FarmEvent}
 end
@@ -61,8 +61,9 @@ Carry out a complete simulation run using a pre-initialised model object.
 function simulate!(model::SimulationModel)
     @info "Simulation run started at $(Dates.now())."
     runtime = Dates.value(@param(core.enddate)-@param(core.startdate))+1
-    #TODO remove Agents.jl-related code, reimplement this more cleanly (#72)
-    step!(model, dummystep, stepsimulation!, runtime)
+    for d in 1:runtime
+        stepsimulation!(model)
+    end
     finalise!(model)
 end
 
@@ -115,7 +116,7 @@ function initmodel(settings::Dict{String, Any})
                                  crops,
                                  Vector{Farmer}(),
                                  Vector{FarmPlot}(),
-                                 Vector{Union{Animals,Nothing}}(),
+                                 Vector{Union{Animal,Nothing}}(),
                                  Vector{Pair{Animal, Date}}(),
                                  Vector{FarmEvent}())
         saveinputfiles(model)
@@ -172,7 +173,7 @@ function stepsimulation!(model::SimulationModel)
         end
         updatenature!(model)
         updateevents!(model)
-        outputdata(model)
+        #outputdata(model) #FIXME
         model.date += Day(1)
         model
     end
@@ -181,13 +182,13 @@ end
 """
     finalise!(model)
 
-Wrap up the simulation. Currently doesn't do anything except print some information.
+Wrap up the simulation. Finalises and visualises output, then terminates.
 """
 function finalise!(model::SimulationModel)
     with_logger(model.logger) do
         @info "Simulated $(model.date-@param(core.startdate))."
         @info "Simulation run completed at $(Dates.now()),\nwrote output to $(@param(core.outdir))."
-        @param(core.visualise) && visualiseoutput(model)
+        #@param(core.visualise) && visualiseoutput(model) #FIXME
         model
     end
 end
diff --git a/src/crop/farmplot.jl b/src/crop/farmplot.jl
index 11605963d9aec0c92fc6e83f5554da3c33ef27dc..328b5bc3db2122365abfcd6f066e382433771a0d 100644
--- a/src/crop/farmplot.jl
+++ b/src/crop/farmplot.jl
@@ -11,7 +11,7 @@
 This represents one field, i.e. a collection of pixels with the same management.
 This is the spatial unit with which the crop growth model and the farm model work.
 """
-mutable struct FarmPlot
+mutable struct FarmPlot <: ModelAgent
     const id::Int64
     pixels::Vector{Tuple{Int64, Int64}}
     croptype::CropType
diff --git a/src/nature/ecologicaldata.jl b/src/nature/ecologicaldata.jl
index 554f8ae6db157c58be578697e60b639170663dde..449895f6d879bed6e1869a8a3650ef71e98febc5 100644
--- a/src/nature/ecologicaldata.jl
+++ b/src/nature/ecologicaldata.jl
@@ -24,13 +24,14 @@ daily, monthly, yearly, or at the end of a simulation, depending on the paramete
 `nature.popoutfreq`.
 """
 function savepopulationdata(model::SimulationModel)
+    #FIXME
     pops = Dict{String,Int}(s=>0 for s = @param(nature.targetspecies))
     for a in model.animals
         isnothing(a) && continue
-        pops[a.name] += 1
+        pops[string(typeof(a))] += 1
     end
-    for a in model.migrants
-        pops[a.first.name] += 1
+    for m in model.migrants
+        pops[string(typeof(m.first))] += 1
     end
     data = []
     for p in keys(pops)
diff --git a/src/nature/macros.jl b/src/nature/macros.jl
index e5072901a421de8d15ef1fddce3f59a633590141..67a1abd60e01f55cad661f66427abf6b97b278da 100644
--- a/src/nature/macros.jl
+++ b/src/nature/macros.jl
@@ -12,8 +12,7 @@
 ## https://github.com/p-i-/MetaGuideJulia/wiki#example-swap-macro-to-illustrate-esc).
 ## Hence all the `esc`apes in the following code - take care when modifying!
 
-##TODO update documentation
-
+##FIXME update documentation
 """
     @species(name, body)
 
@@ -48,39 +47,51 @@ the `model` variable (an object of type `SimulationModel`).
 """
 macro species(name, body)
     quote
-        Core.@__doc__ @kwdef mutable struct $(esc(name)) <: Animal
+        #XXX species are created/referenced as Persefone.<speciesname>, is this relevant?
+        @kwdef mutable struct $(name) <: Animal
             const id::Int64
-            const sex::Sex
-            const parents::Tuple{Int64,Int64} #XXX assumes sexual reprod.
+            const sex::Sex = hermaphrodite
+            const parents::Tuple{Int64,Int64} = (-1, -1) #XXX assumes sexual reprod.
             pos::Tuple{Int64,Int64}
-            phase::Function
+            phase::Function = ()->0
             age::Int = 0
             energy::Union{EnergyBudget,Nothing} = nothing # DEB is optional
             offspring::Vector{Int64} = Vector{Int64}()
-            $(esc(body))
+            $(body.args...)
         end
-        # define a zero-argument constructor to access default field values
-        $(esc(name))() = $(esc(name))(-1, hermaphrodite, (-1, -1), (-1, -1), ()->0)
+        # define a constructor giving the minimum necessary arguments as positional arguments
+        $(esc(name))(id, sex, parents, pos, phase) =
+            $(esc(name))(id=id, sex=sex, parents=parents, pos=pos, phase=phase)
+        # define a zero-argument constructor to access default field values #XXX probably not needed
+        #$(esc(name))(id) = $(esc(name))(id=id, parents=(-1, -1), pos=(-1, -1))
         # allow species to be defined outside of the Persefone module, but still
         # available inside it (needed by `initnature!()` and `reproduce!()`)
-        (@__MODULE__() != $(esc(:Persefone))) && ($(esc(:Persefone)).$name = $(esc(name)))
+        (@__MODULE__() != $(esc(:Persefone))) && ($(esc(:Persefone)).$name = $(name))
     end
 end
 
 """
-    @initialise(habitatdescriptor; kwargs...)
-
-Call this macro within the body of [`@species`](@ref). It passes the given habitat
-descriptor function and keyword arguments on to [`initpopulation`](@ref) when setting
-up the simulation.
+    @populate(species, params)
 
-Note: if this macro is not used, the variable `initialise!` must be set manually in the
-species definition.
+Set the parameters that are used to initialise this species' population.
+For parameter options, see [`PopInitParams`](@ref).
 """
-macro initialise(phase, kwargs...)
-    :($(esc(:initialise!)) = initpopulation($(esc(phase)); $(map(esc, kwargs)...)))
+macro populate(species, params)
+    #TODO I think I can make this simpler by using parametric methods
+    # (see https://docs.julialang.org/en/v1/manual/methods/#Parametric-Methods,
+    # https://stackoverflow.com/questions/42283820/julia-difference-between-type-and-datatype)
+    println("Will register $(species).")
+    quote
+        # convert the macro body to a dict
+        Core.@__doc__ fun = function() # metaprogramming is great - this is a fun fun function ;-)
+            $(esc(params))
+            return Base.@locals
+        end
+        registerpopulationparams($(esc(species)), fun())
+    end
 end
 
+#FIXME update documentation
 """
     @phase(name, body)
 
@@ -94,8 +105,8 @@ such phase, and the conditions under which the animal transitions to another pha
 `@phase` works by creating a function that will be called by the model if the
 animal is in the relevant phase. When it is called, it has access to the following
 variables:
-- `animal` a reference to the animal itself. This provides access to `animal.age`,
-    `animal.sex`, and `animal.<trait>` (where <trait> is a variable that was defined
+- `self` a reference to the animal itself. This provides access to `animal.age`,
+    `self.sex`, and `animal.<trait>` (where <trait> is a variable that was defined
     in the top part of the species definition body).
 - `pos` gives the animal's current position as a coordinate tuple.
 - `model` a reference to the model world (an object of type `SimulationModel`).
@@ -113,12 +124,32 @@ Note that the first phase that is defined in a species definition block will
 be the phase that animals are assigned at birth, unless the variable `phase`
 is explicitly defined by the user in the species definition block.
 """
-macro phase(phaseid, body)
-    species = String(phaseid.args[1])
-    phase = String(phaseid.args[2].value)
+macro phase(species, phase, body)
+    quote
+        Core.@__doc__ function $(esc(phase))($(esc(:self))::$(species),
+                                             $(esc(:model))::SimulationModel)
+            $(esc(:pos)) = $(esc(:self)).pos # needed for landscape macros
+            $(esc(body))
+        end
+    end
+end
+
+"""
+    @create(species, body)
+
+Define a special phase function ([`create!`](@ref)()) that will be called when an
+individual of this species is created, at the initialisation of the simulation
+or at birth.
+
+As for [`@phase`](@ref), the body of this macro has access to the variables
+`self` (the individual being created) and `model` (the simulation world), and
+can thus use all macros available in [`@phase`](@ref).
+"""
+macro create(species, body)
     quote
-        Core.@__doc__ function $(esc(phase))(self::$(esc(species)),
-                                             model::SimulationModel)
+        #XXX species are created/referenced as Persefone.<speciesname>, is this relevant?
+        Core.@__doc__ function $(esc(:create!))($(esc(:self))::$(esc(species)),
+                                                $(esc(:model))::SimulationModel)
             $(esc(:pos)) = $(esc(:self)).pos # needed for landscape macros
             $(esc(body))
         end
@@ -209,7 +240,6 @@ Return an iterator over all animals in the given radius around this animal, excl
 This can only be used nested within [`@phase`](@ref).
 """
 macro neighbours(radius)
-    #FIXME remove Agents.jl code
     #TODO enable filtering by species
     :(nearby_animals($(esc(:self)), $(esc(:model)), $(esc(radius))))
 end
@@ -334,7 +364,7 @@ This is a utility wrapper that can only be used nested within
 [`@phase`](@ref) or [`@habitat`](@ref).
 """
 macro countanimals(args...)
-    :(countanimals($(esc(:pos)), $(esc(:model)), $(map(esc, args)...)))
+    :(countanimals($(esc(:pos)), $(esc(:model)); $(map(esc, args)...)))
 end
 
 ##TODO test movement macros
@@ -346,8 +376,7 @@ Move the current individual to a new position.
 This is a utility wrapper that can only be used nested within [`@phase`](@ref).
 """
 macro move(position)
-    #FIXME remove Agents.jl code
-    :(move_agent!($(esc(:self)), $(esc(position)), $(esc(:model))))
+    :(move!($(esc(:self)), $(esc(:model)), $(esc(position))))
 end
 
 """
@@ -359,9 +388,8 @@ by 2 and decrements the Y coordinate by 3.) This is a utility wrapper that can o
 used nested within [`@phase`](@ref).
 """
 macro walk(direction)
-    #FIXME remove Agents.jl code
     #XXX add `ifempty` keyword?
-    :(walk!($(esc(:self)), $(esc(direction)), $(esc(:model))))
+    :(walk!($(esc(:self)), $(esc(:model)), $(esc(direction))))
 end
 
 """
diff --git a/src/nature/nature.jl b/src/nature/nature.jl
index b50dd8fbce17faba35b68b67a3495cc67378987e..b1aeb9ec696b7819d8ecba8af800bb7605657524 100644
--- a/src/nature/nature.jl
+++ b/src/nature/nature.jl
@@ -13,7 +13,8 @@
     Animal
 
 This is the generic agent type for all animals. Individual species are created
-using the [`@species`](@ref) macro. All species contain the following fields:
+using the [`@species`](@ref) macro. In addition to user-defined, species-specific
+fields, all species contain the following fields:
 
 - `id` An integer unique identifier for this individual.
 - `sex` male, female, or hermaphrodite.
@@ -44,6 +45,17 @@ function animalid(a::Animal)
     return "$(speciesof(a)) $(a.id)"
 end
 
+"""
+    create!(animal, model)
+
+The `create!` function is called for every individual at birth or at model initialisation.
+Species must use [`@create`](@ref) to define a species-specific method. This is the fall-
+back method, in case none is implemented for a species.
+"""
+function create!(a::Animal, model::SimulationModel)
+    @warn "Species $(speciesof(a)) has no create!() method. Use `@create` to add one."
+end
+
 """
     stepagent!(animal, model)
 
@@ -63,7 +75,7 @@ function initnature!(model::SimulationModel)
     # The config file determines which species are simulated in this run
     for speciesname in @param(nature.targetspecies)
         # Call each species' initialisation function
-        @eval $(Symbol(speciesname))().initialise!($(Symbol(speciesname)), model)
+        initpopulation!(speciesname, model)
     end
     # Initialise the data output
     initecologicaldata(model)
diff --git a/src/nature/populations.jl b/src/nature/populations.jl
index d218973752e693dcdac7ac6c293480f8fc517f69..63b49bc3053db4c5c71eb9810ea3cff0e815971e 100644
--- a/src/nature/populations.jl
+++ b/src/nature/populations.jl
@@ -5,98 +5,119 @@
 ###
 
 """
-    initpopulation(habitatdescriptor; popsize=-1, pairs=false, asexual=false)
+    PopInitParams
 
-Creates a function that initialises individuals at random locations across the landscape.
-This can be used to create the `initialise!` variable in a species definition block.
+A set of parameters used by [`initpopulation`](@ref) to initialise the population
+of a species at the start of a simulation. Define these parameters for each species
+using [`@populate`](@ref).
 
-- `habitatdescriptor` is a function that determines whether a given location is suitable
-    or not (create this using [`@habitat`](@ref)).
+- `phase` determines which life phase individuals will be assigned to (required).
 
-- `phase` determines which life phase individuals will be assigned to. If this is `nothing`,
-    the species' default post-natal life stage will be used (although note that this is
-    probably not what you want).
+- `habitat` is a function that determines whether a given location is suitable
+    or not (create this using [`@habitat`](@ref)). By default, every cell will be occupied.
 
-- `popsize` determines the number of individuals that will be created. If this is zero or
-    negative, one individual will be created in every suitable location in the landscape.
-    If `popsize` is greater than the number of suitable locations, multiple individuals
-    will be created in one place.
+- `popsize` determines the number of individuals that will be created, dispersed over the
+    suitable locations in the landscape. If this is zero or negative, one individual will
+    be created in every suitable location. If it is greater than the number of suitable
+    locations, multiple individuals will be created per location. Alternately, use `popdensity`.
 
 - `popdensity`: if this is greater than zero, the chance of creating an individual (or
-    pair of individuals) at a suitable location is 1/popdensity.
+    pair of individuals) at a suitable location is 1/popdensity. Use this as an alternative
+    to `popsize`.
 
 - If `pairs` is true, a male and a female individual will be created in each selected
-    location, otherwise, only one individual will be created at a time.
+    location, otherwise, only one individual will be created at a time. (default: false)
 
 - If `asexual` is true, all created individuals are assigned the sex `hermaphrodite`,
-    otherwise, they are randomly assigned male of female. (If `pairs` is true, `asexual`
-    is ignored.)
+    otherwise, they are randomly assigned `male` or `female`. If `pairs` is true, `asexual`
+    is ignored. (default: false)
 """
-function initpopulation(phase::Function; habitat::Function=@habitat(true),
-                        popsize::Int64=-1, popdensity::Int64=-1, pairs::Bool=false,
-                        asexual::Bool=false)
-    (popsize <= 0 && popdensity <= 0) && #XXX not sure what this would do
+@kwdef struct PopInitParams
+    phase::Function
+    habitat::Function = @habitat(true)
+    popsize::Int64 = -1
+    popdensity::Int64 = -1
+    pairs::Bool = false
+    asexual::Bool = false
+end
+
+"""
+A closure to store the parameters needed to initialise each species' population.
+Users register a new set of parameters using [`@populate`](@ref).
+"""
+#TODO I think I can make this simpler by using parametric methods
+# (see https://docs.julialang.org/en/v1/manual/methods/#Parametric-Methods,
+# https://stackoverflow.com/questions/42283820/julia-difference-between-type-and-datatype)
+let populationparams = Dict{String,Tuple{Type,PopInitParams}}()
+
+    global function getpopulationparams(species::String)
+        populationparams[species]
+    end
+
+    global function registerpopulationparams(species::Type{T}, initparams::Dict{Symbol,Any}) where T <: Animal
+        # convert the dict to an InitParam struct and save it
+        println("Registering $(species).")
+        #XXX why is this necessary?
+        spstrings = split(string(species), ".") # strip out the module name, if necessary
+        speciesstring = length(spstrings) == 1 ? spstrings[1] : spstrings[2]
+        populationparams[speciesstring] = (species, PopInitParams(; initparams...))
+        #populationparams[species] = NamedTuple(k => v for (k,v) in initparams)
+    end
+end
+
+"""
+    initpopulation!(species, model)
+
+Initialise the population of the given species, based on the parameters stored
+in [`PopInitParams`](@ref). Define these using [`@populate`](@ref).
+"""
+function initpopulation!(species::String, model::SimulationModel)
+    s, p = getpopulationparams(species) # species constructor and parameters
+    (p.popsize <= 0 && p.popdensity <= 0) && #XXX not sure what this would do
         @warn("initpopulation() called with popsize and popdensity both <= 0")
-    function(species::Type, model::SimulationModel)
-        n = 0
-        lastn = 0
-        width, height = size(model.landscape)
-        while n == 0 || n < popsize
-            for x in @shuffle!(Vector(1:width))
-                for y in @shuffle!(Vector(1:height))
-                    if habitat((x,y), model) &&
-                        (popdensity <= 0 || @chance(1/popdensity)) #XXX what if pd==0?
-                        if pairs
-                            a1 = @eval($(species))(length(model.animals)+1, male,
-                                                   (-1, -1), (x,y), phase)
-                            a2 = @eval($(species))(length(model.animals)+1, female,
-                                                   (-1, -1), (x,y), phase)
-                            initindividual(a1, model)
-                            initindividual(a2, model)
-                            push!(model.animals, a1)
-                            push!(model.animals, a2)
-                            n += 2
-                        else
-                            sex = asexual ? hermaphrodite : @rand([male, female])
-                            a = @eval($(species))(length(model.animals)+1, sex,
-                                                  (-1, -1), (x,y), phase)
-                            initindividual(a, model)
-                            push!(model.animals, a)
-                            n += 1
-                        end
+    (p.popsize > 0 && p.popdensity > 0) && #XXX not sure what this would do
+        @warn("initpopulation() called with popsize and popdensity both > 0")
+    n = 0
+    lastn = 0
+    width, height = size(model.landscape)
+    while n == 0 || n < p.popsize
+        for x in @shuffle!(Vector(1:width))
+            for y in @shuffle!(Vector(1:height))
+                if p.habitat((x,y), model) &&
+                    (p.popdensity <= 0 || @chance(1/p.popdensity)) #XXX what if pd==0?
+                    if p.pairs
+                        a1 = s(length(model.animals)+1, male, (-1, -1), (x,y), p.phase)
+                        a2 = s(length(model.animals)+1, female, (-1, -1), (x,y), p.phase)
+                        create!(a1, model)
+                        create!(a2, model)
+                        push!(model.animals, a1)
+                        push!(model.animals, a2)
+                        n += 2
+                    else
+                        sex = p.asexual ? hermaphrodite : @rand([male, female])
+                        a = s(length(model.animals)+1, sex, (-1, -1), (x,y), p.phase)
+                        create!(a, model)
+                        push!(model.animals, a)
+                        n += 1
                     end
-                    #XXX break randomly to avoid initialising all individuals in a single column?
-                    (popsize > 0 && n >= popsize) && break
                 end
-                (popsize > 0 && n >= popsize) && break
-            end
-            if lastn == n # prevent an infinite loop - we don't have a Cray...
-                @warn "There are not enough suitable locations for $(string(species)) in the landscape."
-                break
+                #XXX break randomly to avoid initialising all individuals in a single column?
+                (p.popsize > 0 && n >= p.popsize) && break
             end
-            lastn = n
+            (p.popsize > 0 && n >= p.popsize) && break
         end
-        @info "Initialised $(n) $(string(species))s."
+        if lastn == n # prevent an infinite loop - we don't have a Cray...
+            @warn "There are not enough suitable locations for $(species) in the landscape."
+            break
+        end
+        lastn = n
     end
+    @info "Initialised $(n) $(species)s."
 end
 
 #XXX initpopulation with dispersal from an original source?
 #XXX initpopulation based on known occurences in real-life?
 
-"""
-    initindividual(animal, model)
-
-The function that is called when a new animal object is created.
-Has access to the `model` object and carries out any necessary
-initialisation routines.
-
-Note that this is a stump method. Each species should implement its
-own version by defining a [`@phase`](@ref) called `initindividual`.
-"""
-function initindividual(animal::Animal, model::SimulationModel)
-    @debug "No initindividual() method for species $(typeof(animal))."
-end
-
 """
     reproduce!(animal, model, mate, n=1)
 
@@ -107,11 +128,14 @@ function reproduce!(animal::Animal, model::SimulationModel,
                     n::Int64=1, mate::Int64=-1)
     (animal.sex == male) && @warn "Male $(animalid(animal)) is reproducing."
     for i in 1:n
-        sex = (animal.sex == hermaphrodite) ?
-            hermaphrodite :
-            @rand([male, female])
-        child = @eval($(typeof(animal)))(length(model.animals)+1, sex,
-                                         (animal.id, mate), animal.pos)
+        if animal.sex == hermaphrodite
+            sex = hermaphrodite
+        else
+            sex = @rand([male, female])
+        end
+        #FIXME get birth phase
+        child = typeof(animal)(length(model.animals)+1, sex,
+                               (animal.id, mate), animal.pos)
         initindividual(child, model)
         push!(model.animals, child)
         push!(animal.offspring, child.id)
@@ -147,9 +171,11 @@ function migrate!(animal::Animal, model::SimulationModel, arrival::Date)
     # keep model.migrants sorted by inserting the new migrant after migrants
     # that will return earlier than it
     i = findfirst(m -> m.second >= arrival, model.migrants)
-    isnothing(i) ?
-        push!(model.migrants, Pair(animal, arrival)) :
+    if isnothing(i)
+        push!(model.migrants, Pair(animal, arrival))
+    else
         insert!(model.migrants, i, Pair(animal, arrival))
+    end
     model.animals[animal.id] = nothing
     @debug "$(animalid(animal)) has migrated."
 end
@@ -169,6 +195,7 @@ end
 Return an iterator over all animals in the given radius around this position.
 """
 function nearby_animals(pos::Tuple{Int64,Int64}, model::SimulationModel, radius::Int64)
+    #FIXME remove agents.jl code
     #TODO enable filtering by species
     neighbours = (model[id] for id in nearby_ids(pos, model, radius))
     Iterators.filter(a -> typeof(a) == Animal, neighbours)
@@ -180,6 +207,7 @@ end
 Return an iterator over all animals in the given radius around this animal, excluding itself.
 """
 function nearby_animals(animal::Animal, model::SimulationModel, radius::Int64)
+    #FIXME remove agents.jl code
     #TODO enable filtering by species
     neighbours = (model[id] for id in nearby_ids(animal.pos, model, radius))
     Iterators.filter(a -> typeof(a) == Animal && a.id != animal.id, neighbours)
@@ -192,7 +220,7 @@ Count the number of animals in this location (optionally supplying a species nam
 """
 function countanimals(pos::Tuple{Int64,Int64}, model::SimulationModel;
                       species::String="", radius::Int64=0)
-    n = 0
+    n = 0 #FIXME fix nearby_animals
     #XXX can we ignore capitalisation in the spelling of `species`?
     for a in nearby_animals(pos, model, radius)
         (species == "" || a.traits["name"] == species) && (n += 1)
@@ -213,5 +241,66 @@ function followanimal!(follower::Animal, leader::Animal, model::SimulationModel,
     move_agent!(follower, targetposition, model)
 end
 
-##TODO add random walk with habitat descriptor
+"""
+    move!(animal, model, position)
+
+Move the animal to the given position, making sure that this is in-bounds.
+Return true if successful, return false and leave the animal where it is if
+the position is out of bounds.
+"""
+function move!(animal::Animal, model::SimulationModel, position::Tuple{Int64,Int64})
+    width, height = size(model.landscape)
+    if position[1] < 0 || position[2] < 0 || position[1] > width || position[2] > height
+        #XXX should this be a warning? or just pass silently?
+        @debug "$(animalid(animal)) tried to move out of bounds to $position."
+        return false
+    end
+    animal.pos = position
+    return true
+end
+
+"""
+    walk!(animal, model, direction)
+
+Let the animal move one step in the given direction ("north", "northeast",
+"east", "southeast", "south", "southwest", "west", "northwest", "random").
+"""
+function walk!(animal::Animal, model::SimulationModel, direction::String)
+    if direction == "north"
+        shift = (0,-1)
+    elseif direction == "northeast"
+        shift = (1,-1)
+    elseif direction == "east"
+        shift = (1,0)
+    elseif direction == "southeast"
+        shift = (1,1)
+    elseif direction == "south"
+        shift = (0,1)
+    elseif direction == "southwest"
+        shift = (-1,1)
+    elseif direction == "west"
+        shift = (-1,0)
+    elseif direction == "northwest"
+        shift = (-1,-1)
+    elseif direction == "random"
+        shift = Tuple(@rand([-1,1], 2))
+    else
+        @error "Invalid direction in @walk: "*direction
+    end
+    move!(animal, model, animal.pos .+ shift)
+end
 
+"""
+    walk!(animal, model, direction)
+
+Let the animal move in the given direction, where the direction is
+defined by an (x, y) tuple to specify the shift in coordinates.
+"""
+function walk!(animal::Animal, model::SimulationModel, direction::Tuple{Int64,Int64})
+    move!(animal, model, animal.pos .+ direction)
+end
+
+
+
+##TODO add random walk with habitat descriptor
+##TODO add walktoward or similar function (incl. pathfinding?)
diff --git a/src/nature/species/wolpertinger.jl b/src/nature/species/wolpertinger.jl
index 0348d587cc3059eda81757abb341196ba7910bed..20dabb56a2e7b8e5edc073ab3779b33bddddf820 100644
--- a/src/nature/species/wolpertinger.jl
+++ b/src/nature/species/wolpertinger.jl
@@ -11,30 +11,43 @@ It is purported to have the body of a hare, the wings of a bird, and the antlers
 of a deer.
 """
 @species Wolpertinger begin
-    popdensity = 100000
     fecundity = 0.02
     mortality = 0.015
     maxspeed = 5
-    crowding = maxspeed*2
-
-    @initialise(popdensity=popdensity)
+    crowding = 10
 end
 
-
 """
 Wolpertingers are rather stupid creatures, all they do is move around randomly
 and occasionally reproduce by spontaneous parthogenesis...
 """
-@phase Wolpertinger.lifephase begin
-    direction = Tuple(@rand([-1,1], 2))
+@phase Wolpertinger lifephase begin
     for i in 1:@rand(1:self.maxspeed)
-        walk!(animal, direction, model; ifempty=false)
-    end
-
-    if @rand() < self.fecundity &&
-        @countanimals(species="Wolpertinger") < self.crowding
-        @reproduce(-1)
+        @walk("random")
+        #walk!(animal, direction, model; ifempty=false)
     end
+    
+    #FIXME need to fix countanimals() and reproduce() first
+    # if @rand() < self.fecundity &&
+    #     @countanimals(species="Wolpertinger") < self.crowding
+    #     @reproduce()
+    # end
 
     @kill self.mortality
 end
+
+"""
+Wolpertingers are ephemeral creatures that require no special initialisation.
+"""
+@create Wolpertinger begin
+    @debug "$(animalid(self)) created."
+    #TODO
+end
+
+"""
+Population densities of the endangered Wolpertinger are down to 1 animal per 10km².
+"""
+@populate Wolpertinger begin
+    phase = lifephase
+    popdensity = 100000 #XXX use Unitful.jl for conversion?
+end
diff --git a/src/parameters.toml b/src/parameters.toml
index c3d9f57600a718ac1dc7d9a3423ed68d62bdf915..60397d728fc19a58334bb77cfa73dc052eb507a0 100644
--- a/src/parameters.toml
+++ b/src/parameters.toml
@@ -30,7 +30,7 @@ weatherfile = "data/regions/jena-small/weather.csv" # location of the weather da
 farmmodel = "FieldManager" # which version of the farm model to use (not yet implemented)
 
 [nature]
-targetspecies = ["Wolpertinger", "Wyvern"]#["Skylark"] # list of target species to simulate
+targetspecies = ["Wolpertinger"]#, "Wyvern"]#["Skylark"] # list of target species to simulate
 popoutfreq = "daily" # output frequency population-level data, daily/monthly/yearly/end/never
 indoutfreq = "end" # output frequency individual-level data, daily/monthly/yearly/end/never
 insectmodel = ["season", "habitat", "pesticides", "weather"] # factors affecting insect growth