[ANN] Ark.jl: archetype-based entity component system (ECS) for games and simulations

Is Ark.jl version bound to Julia < 1.11? I’m on v1.11.7 and would like to try it out, but got an unsatisfiable package requirements error when installing: restricted by julia compatibility requirements to versions: uninstalled — no versions left

The converse. The registered version has Julia >= 1.12 compat.
The current Project.toml has 1.10 compat though so you should be able to

pkg> add Ark#main

or just use Julia 1.12 with the registered version.

2 Likes

Thanks @GunnarFarneback.

I’m trying out this package, although I have only a dim idea of how ECS frameworks are used.
@mlange-42 could you help me understand why in my small example below, I am only able to retrieve the count of entities with the S type component, and when retrieving the count for entities with I type, I get 0? Maybe I’m using this system in a non-canonical way.

using Ark

N::Int = 1_000
I0::Int = 5

abstract type HealthState end
struct S <: HealthState end
struct I <: HealthState end
struct R <: HealthState end

world = World(S,I,R)

for _ in 1:N-I0
    new_entity!(world, (S(),))
end

for _ in 1:I0
    new_entity!(world, (I(),))
end

function get_count(world, ::Type{T}) where {T<:HealthState}
    count = 0
    for (entities, _) in @Query(world, (T, ))
        count = length(entities)
    end
    return count
end

get_count(world, S) # returns 995
get_count(world, I) # returns 0 != 5

You should use count += ..., currently you overwrite the count. However, this does not really explain it, as archetypes are pre-selected and empty archetypes are skipped. I can take a closer look in 1-2h.

EDIT: @slwu89 which version are you using?

1 Like

@slwu89 I can reproduce this on v0.1.0, while it is fixed on main. So this is definitely a bug in v0.1.0. Will have to dig deeper to find the issue.

1 Like

we fixed this some time ago in Fix component selection in query iteration by Tortar · Pull Request #247 · mlange-42/Ark.jl · GitHub, while I think @mlange-42 is fixing this in v0.1 with a new release v0.1.1, another solution is to use the dev version which will be released as v0.2 not too long from now

1 Like

@slwu89 Made a hotfix release v0.1.1 for that. Thank you for reporting!

To be sure to get such things noticed, I recomment using the GitHub repo’s issue tracker next time.

1 Like

Sure, will use GitHub issues next time, thanks for the help @mlange-42 and @Tortar!

2 Likes

@mlange-42 and @Tortar would you be interested to provide any feedback about my use of the library in a simple example below (i.e. any obvious inefficiencies)? I’m reproducing this simple discrete time SIR model example from the docs (which I co-authored, for disclosure) of Tutorial • individual.

using Ark, Plots

# "parameters"
N::Int = 1_000
I0::Int = 5
tmax::Float64 = 100
dt::Float64 = 0.1
steps::Int = Int(floor(tmax/dt))
gamma::Float64 = 1/10
R0::Float64 = 2.5
beta::Float64 = R0 * gamma

abstract type HealthState end
struct S <: HealthState end
struct I <: HealthState end
struct R <: HealthState end

world = World(S,I,R)

for _ in 1:N-I0
    new_entity!(world, (S(),))
end

for _ in 1:I0
    new_entity!(world, (I(),))
end

function get_count(world, ::Type{T}) where {T<:HealthState}
    count = 0
    for (entities, _) in @Query(world, (T, ))
        count += length(entities)
    end
    return count
end

# output
trajectory = zeros(Int, steps+1, length(subtypes(HealthState)))
trajectory[1, :] = [get_count(world, T) for T in (S, I, R)]

# run sim
for t in 1:steps
    # S->I
    i = get_count(world, I)
    foi = beta * i/N
    prob = 1-exp(-foi*dt)
    s_to_i = Entity[]
    for (entities, _) in @Query(world, (S, ))
        @inbounds for i in eachindex(entities)
            if rand() <= prob
                push!(s_to_i, entities[i])
            end
        end
    end
    # I->R
    prob = 1-exp(-gamma*dt)
    i_to_r = Entity[]
    for (entities, _) in @Query(world, (I, ))
        @inbounds for i in eachindex(entities)
            if rand() <= prob
                push!(i_to_r, entities[i])
            end
        end
    end
    # apply transitions
    for entity in s_to_i
        @exchange_components!(world, entity, 
            add    = (I(),),
            remove = (S, ),
        )
    end
    for entity in i_to_r
        @exchange_components!(world, entity, 
            add    = (R(),),
            remove = (I, ),
        )
    end
    # record state
    trajectory[t+1, :] = [get_count(world, T) for T in (S, I, R)]
end

plot(
    trajectory,
    label=["S" "I" "R"],
    xlabel="Time",
    ylabel="Number"
)
2 Likes

Thank you @slwu89 for sharing this example!

Looking over the code, it looks good to me. The only obvious optimization is to define the entitiy vectors outside the loop and to clear them with resize! at the start or end of each iterations. This way, you can avoid repeated allocations.

Of course, and you are certainly aware, this simple model could be expressed in a more efficient way. As it does not actually need individualy, one could just use three integer variables and move numbers between them based on poisson-distributed random numbers. I.e. a classical compartmental model.

But given that this is just a very simplified example, the use case is totally valid. There could be other processes going on, entities and your components could have state variables like susceptibility, incubation period, recovery period, … Using an ECS would make perfectly sense then.

EDIT: One more point: If you query for components that you don’t actually access in a query but just want to use them to filter your entities, you can use the query’s with argument to make it a little bit more efficient.

2 Likes

Another one, to speed up initialization: you can use batch entity creation:

new_entities!(world, N-I0, (S(),))
1 Like

FIY, I have rewrote it taking into account the suggestion by @mlange-42 plus some more general tips:

using Ark, Random, Plots

# "parameters"
const rng = Xoshiro(42)
const N::Int = 1_000
const I0::Int = 5
const tmax::Float64 = 100
const dt::Float64 = 0.1
const steps::Int = Int(floor(tmax/dt))
const gamma::Float64 = 1/10
const R0::Float64 = 2.5
const beta::Float64 = R0 * gamma

abstract type HealthState end
struct S <: HealthState end
struct I <: HealthState end
struct R <: HealthState end

function get_count(world, ::Type{T}) where {T<:HealthState}
	count = 0
	for (entities, ) in @Query(world, (); with=(T, ))
	    count += length(entities)
	end
	return count
end

function run_sir()
	world = World(S,I,R)

	new_entities!(world, N-I0, (S(),))
	new_entities!(world, I0, (I(),))

	# output
	trajectory = zeros(Int, steps+1, length(subtypes(HealthState)))
	trajectory[1, :] = [get_count(world, T) for T in (S, I, R)]

	# run sim
	i_to_r = Entity[]
	s_to_i = Entity[]
	for t in 1:steps
	    # S->I
	    i = get_count(world, I)
	    foi = beta * i/N
	    prob = 1-exp(-foi*dt)
	    for (entities, ) in @Query(world, (); with=(S, ))
	        @inbounds for i in eachindex(entities)
	            if rand(rng) <= prob
	                push!(s_to_i, entities[i])
	            end
	        end
	    end
	    # I->R
	    prob = 1-exp(-gamma*dt)
	    for (entities, ) in @Query(world, (); with=(I, ))
	        @inbounds for i in eachindex(entities)
	            if rand(rng) <= prob
	                push!(i_to_r, entities[i])
	            end
	        end
	    end
	    # apply transitions
	    for entity in s_to_i
	        @exchange_components!(world, entity; add = (I(),), remove = (S,))
	    end
	    for entity in i_to_r
	        @exchange_components!(world, entity; add = (R(),), remove = (I,))
	    end
	    # record state
	    trajectory[t+1, :] = [get_count(world, T) for T in (S, I, R)]
	    resize!(i_to_r, 0)
	    resize!(s_to_i, 0)
	end
	return trajectory
end

trajectory = run_sir()

plot(
    trajectory,
    label=["S" "I" "R"],
    xlabel="Time",
    ylabel="Number"
)

(this uses the dev version of the package)

2 Likes

Thanks @mlange-42 and @Tortar for the advice!

Yes as you correctly pointed out, in this case since all the entities sharing a HealthState are identical we only need a length 3 vector to run the same model. But I’m interested in exploring ECS frameworks for more complex modeling cases, this is always a good warm up (the “hello world” of agent-based models :slightly_smiling_face:)

1 Like

@Tortar As far as I can see, it does not use any unreleased new features, right? So it should also work with v0.1.1.

There is the little difference in macro handling kwargs (; instead of ,)

When prototyping and trying out different queries, sometimes errors might be thrown in the query iteration block, leaving the world in a locked state. Perhaps the query is not available to the caller. What is the recommended way to deal with this (other than recreating the world)? Might there be a case for including a try catch in the query iteration, always making sure that the query is closed and then rethrowing the exception?

@simsurace Do you mean user code throwing errors, or do you encounter errors thrown from Ark itself?

If you are referring the former, I am not sure what you mean by “Might there be a case for including a try catch in the query iteration”. I don’t think that Ark can do anything about that.

However, you can create the query before the loop and add a try/catch with close! yourself, like this:

query = @Query(world, (Position, Velocity))
for (entities, positions, velocities) in query
    try
        error("code that can error")
    catch e
        close!(query)
        throw(e)
    end
end

Or you just close! the query manually after an error.

Yeah, one can certainly do that, but having to write that in every system might be a bit repetitive. What I meant with my suggestion was to include the try catch in the `Base.iterate` method for `Query`.

Maybe we can just add a unlock!(world) method which unlocks the world?

I skimmed through some of the docs, so maybe I missed it – but is there a simple self-contained explanation of a scenario when one would use such a package in Julia?

And some specific Qs from me trying to understand it:
Presumably, “agents” in the description mean something different than llm agents (arguably the most common meaning now :slight_smile: )? Is it about game/society simulation?
What’s the relationship to plotting? I see a lot of Makie integration discussions in this thread, but fail to understand what the connection is between ecs and plotting.