A bit from the fourth prototype level. I posted some gameplay footage here.
- Cleaned up the codebase, deleting features that weren’t in use
- Revisited actor state management, replacing the clunky and almost entirely unused routineState system with something lighter.
- Implemented a very basic kind of inheritance for actors, referred to as subkinds
- Rewrote actor sleeping service, and the mechanism that wakes groups of actors from sleep
- Implemented a continuity tag variable which can be used to ID actors that are behind the current checkpoint, and therefore shouldn’t be spawned in (or if they are, should be removed)
- Finished up the fourth prototype level, which was started in January but largely neglected since then
Added some write-guard __newindex metamethods to tables with fixed contents (such as actors and sprites.) This immediately caught a few incorrect assignments.
Lua 5.1’s metamethods aren’t able to provide full write protection, at least not without doing some gymnastics with table references. With __newindex, I can at least guard against writing new fields to tables which are supposed to only have static fields. This shouldn’t impact performance much, since __newindex only triggers if the assignment is to an unassigned key.
One complication is that you can’t assign nil to existing fields in a guarded table, since further assignments will be blocked. (Well, technically, you could if you briefly remove the metatable or use rawset().)
Actor Refresh Rates / Sprite Position Extrapolation
These features allowed an actor to update at a lower interval than the full tick-rate. A low-refresh actor’s position would be extrapolated between “off” frames to give the impression that it was continuing to move and update.
Removed because nothing actually used them.
I’ve have played around with asynchronous message inboxes a few times, but never properly integrated them into the project. I deleted the latest incarnation since nothing actually uses it. I’ve found it easier to work with synchronous events.
Targets, Hunting, Leader-picking, Inter-group fighting
Last year, I put some work into letting actors select targets and “leaders” using a timed search function. This would allow things such as:
- In the absence of the player, enemies of one group could target enemies belonging to another, and attack them like they would the player.
- Target selection (‘hunting’) could take various facts into account beyond just distance. For example, a melee enemy could give less or no weight to airborne targets, which it might not be capable of hitting.
- Enemies in close proximity could form a “chain of command” linked list, such that enemies lower in rank automatically acquire the target details of those higher on the list. The hope was that this would reduce target selection overhead.
That is all well and good, and would be worth keeping if the circumstances were different. However, the game is narrowing in scope, and I really want the focus to be player vs enemies, not player vs enemies vs other enemies. What good are two enemies fighting each other if they’re just out of view? Because that’s what will happen over and over, as enemies wake up upon the player getting within 1.5 screens of distance.
The chain of command idea also falls flat: if a dozen spread-out enemies form a command chain, they will only target and attack opponents close to the leader. If the leader is on the left side of the group, then comrades on the right side won’t target anything close to them, and vice versa for the other direction.
For this game, essentially, there are only two targets that really matter to enemies: the player, and the player’s shot. In both cases, the population ranges from 0 to 1. So a search function isn’t necessary. Just check if player/shot exists, calculate its proximity, and go from there.
The only bit from this work that remains is parent-to-children relationships for actors. These references are helpful in terms of pinning together components, and a child actor can automatically unload if its parent is no longer in the scene.
Blackboard -> Corkboard
In July 2020, I started a blackboard table for shared info, but didn’t really do anything with it until now. I renamed it to corkboard because its short form (scene.cork) is unlikely to be confused for other engine variables. Its primary use case is to cache up-to-date table references to the player, the player’s shot, and the action_manager, which maintains session continuity when the player actor is destroyed.
This info can be gathered without the corkboard, but it’s generally less hassle to store copies of it in one spot. Similar to the hunting code above: before, every actor that needed to grab a reference to the player had to search the entire actor list, then save a copy of the actor table and index details. Subsequent searches would first check that the index was up-to-date, and short-circuit the search if the actor reference was still good. This info is so frequently looked-up that it might as well have a pseudo-global lookup point.
- Player, shot_player XY positions from the past quarter-second
- World scrolling position, viewport edges and dimensions
- Time since player last took damage
- The actor responsible for inflicting the last successful player hit
- The most recent enemy that the player damaged
- Time since last shot_player was launched
- Player’s current defensive state
“Opponent” Service -> “Hit Response” Service
I renamed the main enemy logic service from opponent to injurable, and then later to hit_response. While intended to be the dumping ground for bog-standard enemy behavior, it was really only used to implement reactions to damage collision events: checking protected status, deducting hitpoints, ticking the “flinch” timer, etc. The service was also being applied to entities such as barriers and boxes, which don’t fit the opponent label.
Hitbox bitmask category rework
I had to sweep the bitmask categories in order to purge enemy sub-faction designations, so I took the opportunity to revamp the whole bitmask. There are four broad categories for non-player ‘performers’:
- enemies / hurts_enemies: the usual adversaries
- neutral / hurts_neutral: level props, devices
- hazards / hurts_hazards: level-bound dangers, typically indestructible
- shots / hurts_shots: projectiles fired by enemies or hazards
These are not strict designations. Some actors blur the lines between categories, while others are part of a larger contraption with sibling actors that are not alike. The categories are just a way to filter out irrelevant collision events for a given actor. For example, the vast majority of small projectiles fired by enemies don’t need to evaluate collisions with enemies or other small projectiles. And enemies generally shouldn’t be hurting other enemies, but they might take damage from hazards.
Actor-to-Actor collisions: Asynchronous to Synchronous
I might end up reverting this one in the future.
Before, when a collision was identified, details of the event were appended to per-actor arrays, and the actor would deal with its collision events later on when processing its tick callback. This event was just one piece of information: a reference to the colliding actor table. With multi-hitboxes, I had to expand this to three references: the collider, self’s hitbox, and the collider’s hitbox.
These collision event tables were implemented as ‘dirty arrays’, where the contents of the array are not automatically cleaned out, and a separate variable keeps track of the actual end of valid data. They were also pooled, so over the course of gameplay, these arrays would progressively fill up with dead collision info as tables got passed around from actor to actor.
I don’t believe this was a huge problem, but I also didn’t see why collisions couldn’t be dealt with in the big nested loop that figures out what’s touching what. There is one potential issue: due to how the loop is written, responses to collisions wouldn’t exactly follow the order of actors in the scene. For example, if we have four actors, and actor 1 collides with actor 4, and actor 2 collides with actor 3, the two systems would process the collision responses in this order:
Old: 1:4, 2:3, 3:2, 4:1 New: 1:4, 4:1, 2:3, 3:2
This might cause weird ordering issues. I guess if it ends up being a problem, switching back won’t be too difficult.
Actor State Management
I have a lot of actor definitions with 90% identical setup boilerplate. Adding or changing a feature that deals with actors often means going through every definition file. It’s been a drag. Using OOP-style inheritance from the outset probably would have saved me a lot of trouble.
It’s too late to start over from scratch, so I felt the best way forward was to adapt general-purpose actor definitions to support many types of performers. This was already the case with gp_projectile, which implements most of the enemy bullets in the game, and I got the ball rolling earlier with gp_block, gp_hazard, and gp_targetable, which are typically spawned and managed by a parent actor. I needed something like gp_enemy or gp_performer.
Last year, I whipped up a scripting system for actors called routineStates. I lost the plot and over-engineered it, ultimately making a kind of toy language that nothing important ever used. I started a new system with a smaller scope.
At the very lowest level: actors now have a tick_fn field, which is either a function or false. This replaces the per-kind tick callback. Unlike tick, which was determined by the actor’s kind designation, tick_fn can be changed at run-time. One could build a simple state machine by writing a collection of functions that overwrite self.tick_fn when it’s time to change states. Accompanying this is self.timer, a number which always ticks up by an amount stored in self.timer_i. States can (but don’t have to) use this value as a way to impose a time limit on a state.
One level above this is self.routine, which holds a sequence of tables containing commands and controls. A command holds a state function, a duration value, and an optional parameters table (which is just a totally arbitrary table.) If a routine is specified and active, then the current command’s details will be applied to the actor whenever self.timer equals or exceeds self.timer_max. The actor will step through the routine sequence until it reaches the end, or until hits a control table. Controls include: defining a label, jumping to a label, halting, terminating (stop the routine and wipe the actor’s state details), and restarting from the first index.
A layer of indirection is provided with self.role, which ties state function callbacks to string identifiers. These are invoked in a routine with role-actions. Two actors can share the same routine while implementing different states in their roles.
Tying this all together are actor subkinds, which define additional startup and cleanup callback functions, and store default tick_fn, routine and role designations in relation to a base actor kind.
What this accomplishes for me:
- Similar enemies can be converted to subkind definitions which use one base actor.
- Single-purpose actors can be designed as custom function-swapping state machines without involving the entire routine + role system. Existing actors built on :tick() can be converted to non-routine, single-state actors without much trouble.
- The routine + role system is available for things that need modular sequences of states.
What’s missing compared to the old routineStates?
- RoutineState commands supported function callbacks for entering or exiting a state. These could potentially cause ordering problems, because they’d fire as soon as something caused a routineState to advance or jump around.
- RoutineStates had stackable frame tables: you could invoke another routine with a new frame, and when it completed, the previous frame would resume execution. This could ostensibly be used to implement behavior trees (except, you know, without the visual tree editor that makes it easy to follow what’s going on.) Currently, no in-game actors have a need for stacking states of this nature.
- There was a text parser. It would take specially formatted lines of text and convert them into arrays of Lua tables. Making the tables by hand was a huge pain in the neck, but I found a reasonable compromise that doesn’t require maintaining a fragile ad hoc text parser: write functions to add the relevant types of tables to a routine, and attach them to the routine table as OOP-style __index methods.
Sleeping Service -> Dormant State
When actors are spawned out of view, it’s helpful to keep them in a semi-paused state where they can’t wander around or make noises until the viewport approaches them. I originally programmed this as a service, but the implementation didn’t work well in the context of the new routine-based state system. Actors would check in their tick() callback if data.sleeping was true, and if so, immediately exit the function. Now that tick callbacks can be swapped in and out, I didn’t want every function to start with this. Instead, I wrote a shared state function that makes any actor assigned to it do effectively nothing except to check if it has woken up, and if so, advance to the next routine command.
The old sleep system allowed actors that have just woken up to also wake their neighbors, if they were within a certain range. This was easy to set up: just put the spawnpoints close together, set them to sleeping, and they would activate in unison. But there were practical issues. Each waking actor had to check the entire list of actors for neighbors to rouse, and accidentally placing spawnpoints too far away from each other would disrupt the wake-up chain.
My solution was to assign wake-group tags to spawnpoints. When one actor in a wake-group is roused, it sets a scene-wide flag indicating that everything in that group needs to activate.
Wake-group example. The ‘C’ is the camera center surrounded by the viewport. The grey perimeter is how close actors need to be to ‘C’ to wake up. When the rightmost actor in wake-group 1 gets in range, then all four of the actors in the group will rouse. In the old wake-neighbors-by-proxy system, all seven actors would have awakened at the same time.
Continuity numbers help identify which spawnpoints can be ignored due to being behind the current in-game location, and they can also ID actors that should be unloaded for the same reason. While primarily assigned at checkpoint triggers, this number could also be set by actors such as one-way doors. Spawnpoints and actors with continuity tags will be ignored and removed respectively if their continuity value is less than or equal to the session continuity number.
Continuity numbers go lower-to-higher, but they don’t need to be in perfect sequential order, and they don’t need to correspond to checkpoint indices. This allows for basic support of forking paths, though it would present issues for looping routes. In the future, if more control is needed, I might add a max continuity value to cull spawnpoints ahead of the current area as well.
An example of continuity tags in a level with a forking path.
S == start point
C == checkpoint, plus the continuity tag it assigns
e## == the continuity tag for enemies in this region
E == End of level
Proto Level 4
View from the map editor.
I started Proto 4 in January, following a productive December in which I slapped together three levels. I ended up sidelining Proto 4 in favour of rewriting technical features, only getting back to it in the last half of June.
It’s not a good level, but it does organize some ideas and actors from test maps into something that’s playable.
Bug Of The Month
The status bar could fall down pits and die.
action_manager is a hidden actor which handles some behind-the-scenes tasks, such as implementing the status bar, managing level transitions when the player is dead, and so on. While updating the behavior file syntax for each actor, I accidentally added the platformer service to action_manager. The platforming state table has a convenience feature for removing actors which collide with “pit of death” marker terrain, and this feature is on by default.
For debugging purposes, action_manager is always positioned at the center of the viewport. (It’s invisible, but you can view its position, vector and hitbox with debug visualizations.) Because all pit-death terrain is intended to be off-screen, it’s unlikely for action_manager to be destroyed in this manner. However, if the player is outside of a scroll-zone (which is common with levels that are under construction), the camera defaults to centering the viewport on the player. By coincidence, this puts action_manager in the same spot as the player, and if the player touches a death-pit tile, then there’s a good chance that action_manager will touch it as well. The player dies, the status bar disappears, and no level reload occurs.
action_manager doesn’t need platforming physics whatsoever, so removing svc_platformer from its services list resolved the issue.
Proto 4 is finally off my back. These levels do not possess the level of quality that I hope the final game will have, but they do provide a general idea of how it’s going to work. It’s certainly a better demonstration than a bunch of fragmented test maps with no beginning or end.
I believe next month will be mostly content development as opposed to technical stuff. Either that, or a reevaluation of the game’s background and storyline (or lack thereof.) I plan to get another update out at the end of July.
Codebase Issue Tickets: 45
Total LOC: 106341
Core LOC: 36180
Estimated Play-through Time: 6:00.89