Last active
May 6, 2025 03:16
-
-
Save randyprime/065370cfa73c0dd3cb008eb858e6ba4b to your computer and use it in GitHub Desktop.
ENTITY MEGASTRUCT
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// the tl;dr -> https://storage.randy.gg/entity%20midwit.png | |
/* | |
ENTITY MEGASTRUCT | |
by randy.gg | |
This is an extremely simple and flexible entity structure for video games that doesn't make you want to | |
die when you're 20k lines deep in a project. | |
pros: | |
- you never have to think about the ideal entity structure again and can get back to working on things that | |
actually matter (actually using it to add new entities and making your game better, instead of overthinking) | |
- has all of the reuse power of an Entity Component System (ECS) | |
- doesn't have the complexity of an ECS | |
- you don't have to think about where to put that one new piece of data you need while in the middle of gameplay programming | |
- you can do easy serialisation by just copying the bytes over | |
cons: | |
- it seems "messy" and wasteful | |
- probably won't get you laid | |
If you're heavily memory constrained, you'll probs want to upgrade this into a discriminated union with a shared Entity base | |
structure. Or even dynamically allocate each new entity. But that comes with extra complexity. Don't pay it unless you have to. | |
I used it in these games: | |
https://store.steampowered.com/app/2571560/ARCANA/ | |
https://store.steampowered.com/app/3309460/Demon_Knives/ (we used the more complicated variation I mentioned earlier, | |
except it was probably overkill in hindsight) | |
https://store.steampowered.com/app/3433610/Terrafactor/ | |
It holds up incredibly well, even as you scale it up. | |
I got this idea from Ryan Fleury a few years ago. Been using it every single day ever since. | |
--- | |
This is Odin style pseduo-code and won't actually compile. | |
There's a few missing pieces and extra things you need when scaling this up, but it's a good enough overview to | |
get the point across. | |
*/ | |
// | |
// the data structures | |
// | |
// you can crank this however high you want. In Terrafactor I've got mine at 65,536 (with some extra things for | |
// looping over them properly) | |
MAX_ENTITIES :: 1024 | |
game_state: Game_State | |
Game_State :: struct { | |
initialized: bool, | |
entities: [MAX_ENTITIES]Entity, | |
entity_id_gen: u64, | |
entity_top_count: u64, | |
world_name: string, | |
player_handle: Entity_Handle, | |
} | |
EntityKind :: enum { | |
nil, | |
player, | |
goblin, | |
ogre, | |
big_boss_goblin, | |
wood_spikes, | |
defense_wall, | |
} | |
Entity :: struct { | |
allocated: bool, | |
handle: Entity_Handle, | |
kind: EntityKind, | |
// could pack these into flags if you feel like it (not really needed though) | |
has_physics: bool, | |
damagable_by_player: bool, | |
is_mob: bool, | |
blocks_mobs: bool, | |
// just put whatever state you need in here to make the game... | |
position: Vector2, | |
velocity: Vector2, | |
acceleration: Vector2, | |
hitbox: Vector4, | |
hit_cooldown_end_time: float64, | |
health: int, | |
next_attack_time: float64, | |
sprite_id: SpriteID, | |
current_animation_frame: int, | |
// ... | |
// Constant Entity Data | |
// | |
// this is constant based on the kind of the entity | |
// you could put this somewhere else if you want, I like having it inside the entity for easy access though. | |
// the 'using' is Odin/Jai specific and just makes it so you can: | |
// 'entity.max_health' instead of 'entity.const_data.max_health' | |
// | |
using const_data: Const_Entity_Data, | |
} | |
Const_Entity_Data :: struct { | |
update: proc(^Entity), | |
draw: proc(^Entity), | |
icon_image: SpriteID, | |
max_health: int, // you could move this back into the Entity struct to make it dynamic, and no existing code would break | |
} | |
// | |
// creating / destroying | |
// | |
entity_create :: proc(kind: Entity_Kind) -> ^Entity { | |
// look through game_state.entities and grab the first one that isnt 'allocated' | |
// (could also use a free list, and use straight from entity_top_count when that's empty) | |
new_index := -1 | |
new_entity: ^Entity = ...... | |
for entity, index in game_state { | |
if !e.allocated { | |
new_entity = entity | |
new_index = index | |
break | |
} | |
} | |
if new_index == -1 { | |
log.error("out of entities, probably just double the MAX_ENTITIES") | |
return nil | |
} | |
game_state.entity_top_count += 1 | |
// then set it up | |
new_entity.allocated = true | |
game_state.entity_id_gen += 1 | |
new_entity.handle.id = game_state.entity_id_gen | |
new_entity.handle.index = new_index | |
// could add whatever defaults in here | |
new_entity.draw = default_draw_based_on_entity_data | |
switch kind { | |
case .player: setup_player(new_entity) | |
case .goblin: setup_goblin(new_entity) | |
case .wood_spikes: setup_wood_spikes(new_entity) | |
// ... | |
} | |
return new_entity | |
} | |
entity_destroy :: proc(entity: ^Entity) { | |
entity^ = {} // it's really that simple | |
} | |
// | |
// handles | |
// | |
// Use this for storing entities instead of pointers for any long-ish period of time. | |
// If you're holding a pointer, and the thing has a chance of being destroyed, use a handle | |
// instead so it doesn't kill your game. | |
Entity_Handle :: struct { | |
index: u64, | |
id: u64, | |
} | |
entity_to_handle :: proc(entity: ^Entity) -> Entity_Handle { | |
return entity.handle | |
} | |
// | |
// Having a zero return value (instead of using a null pointer) is a very useful concept for not having to | |
// deal with null pointer crashes. | |
// | |
@(rodata) // marks this as read-only data, crashes when you try to write. | |
zero_entity: Entity | |
handle_to_entity :: proc(handle: Entity_Handle) -> ^Entity { | |
if handle == {} { | |
return &zero_entity | |
} | |
entity := &game_state.entities[handle.index] // might wanna do some extra bounds checks on this first | |
if entity.handle.id == handle.id { | |
return entity | |
} else { | |
// the entity has been destroyed, and there's a new one in this slot | |
return &zero_entity | |
} | |
} | |
// | |
// When you get &zero_entity in a return value, instead of a null pointer, you can safely access it. | |
// Since the entire thing is zeroed, a lot of your logic / algorithms will just gracefully fail. | |
// | |
/* for example | |
handle_that_is_invalid := Entity_Handle{} | |
entity := handle_to_entity(handle_that_is_invalid) | |
if entity.allocated { // this won't crash, just read a zero and gracefully skip | |
do_something() | |
} | |
*/ | |
// | |
// SETUP (where the content magic happens) | |
// | |
// | |
// The setup is designed to write into both the main dynamic Entity structure | |
// and the Const_Entity_Data structure. | |
// | |
// That way everything you need to add a new piece of content, ie - an enemy, build, item, etc | |
// ... is all localised in the one place. | |
// | |
// This becomes very important for the speed of adding new stuff in. What I usually do is just copy from the | |
// most similar existing entity as a starting point. | |
// | |
setup_player :: proc(entity: ^Entity) { | |
entity.kind = .player | |
entity.has_physics = true | |
entity.max_health = 100 | |
// update function is also nice and localised here | |
entity.update = proc(entity: ^Entity) { | |
entity.health = entity.max_health | |
// overlap a hitbox and do hit stuff | |
if key_just_pressed(.LEFT_MOUSE) { | |
for against in game_state.entities { | |
if !against.allocated || !against.damageable_by_player do continue | |
// ... hitbox checking stuff | |
} | |
} | |
if entity.vel != {} { | |
entity.sprite_id = .running | |
} else { | |
entity.sprite_id = .idle | |
} | |
} | |
entity.draw = proc(entity: ^Entity) { | |
default_draw_based_on_entity_data(entity) | |
// could add extra stuff to the draw like a sword or other items in the player's hand | |
// ... | |
} | |
} | |
setup_goblin :: proc(entity: ^Entity) { | |
entity.kind = .goblin | |
entity.is_mob = true | |
entity.damageable_by_player = true | |
entity.max_health = 200 | |
entity.update = proc(entity: ^Entity) { | |
// ... AI stuff (topic for another day) | |
} | |
} | |
setup_wood_spikes :: proc(entity: ^Entity) { | |
entity.kind = .wood_spikes | |
entity.max_health = 200 | |
entity.sprite_id = .wood_spikes_sprite // just a static sprite | |
// entity.draw = ... // we can leave this blank to use the default | |
entity.update = proc(entity: ^Entity) { | |
// It's 100% fine in the early days to just loop through all entities like this. | |
// Make it faster later when you actually notice it become a problem. Not before. | |
for against in game_state.entities { | |
if !against.allocated do continue | |
// todo - check for overlapping mobs so we can damage them | |
} | |
} | |
} | |
// | |
// main entry, update, and rendering | |
// | |
// not a full example of a main loop | |
main :: proc() { | |
for { | |
update() | |
render() | |
} | |
} | |
update :: proc(delta_t: float64) { | |
for entity in entities { | |
if !entity.allocated do continue | |
// call the update function | |
entity.update(entity) | |
if entity.has_physics { | |
// do some epic physics stuff (topic for another day) | |
entity.vel += entity.acc * delta_t | |
entity.pos += entity.vel * delta_t | |
entity.acc = 0 | |
// could do some collision resolution stuff in here... | |
} | |
// you might even want to split the update into pre-physics and post-physics | |
// entity.post_physics_update(entity) | |
} | |
// could do some other stuff out here | |
// Like if things every become slow inside an entity update, break them out and optimise | |
// a larger all-in-one pass | |
for entity in entitites { | |
// ... do some operation in bulk on them or something | |
} | |
} | |
// (a very incomplete example, just showing off the entity draw) | |
render :: proc() { | |
for entity in entities { | |
if !entity.allocated do continue | |
entity.draw(entity) | |
} | |
} | |
default_draw_based_on_entity_data :: proc(e: Entity) { | |
// draw_sprite stuff based on e.pos, e.sprite_id, etc ... | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment