Skip to content

Instantly share code, notes, and snippets.

@RandyGaul
Last active March 20, 2026 13:14
Show Gist options
  • Select an option

  • Save RandyGaul/bc2ae030f806ff41dd75e6d67bfbab9b to your computer and use it in GitHub Desktop.

Select an option

Save RandyGaul/bc2ae030f806ff41dd75e6d67bfbab9b to your computer and use it in GitHub Desktop.
Coro powered FSM
// Coroutine-powered state machine. Each state is a separate coroutine function.
// A generic driver manages one state coroutine at a time.
//
// Example:
// init_sm(e, MY_IDLE,
// [MY_IDLE] = my_idle_fn,
// [MY_ATTACK] = my_attack_fn,
// );
// e->sm.on_exit = my_on_exit;
// e->sm.on_enter = my_on_enter;
typedef struct StateMachine
{
int state; // Current state (readable externally).
int next; // Pending transition (-1 = none). Set by sm_set (inside state) or sm_request (outside).
bool paused;
CF_Coroutine co;
void (**fns)(CF_Coroutine); // Pointer to static state function table.
int num_fns;
void (*on_exit)(Entity*, int from, int to);
void (*on_enter)(Entity*, int to, int from);
} StateMachine;
// Set next state, can be done from within a state, or externally.
#define sm_set(sm, s) do { (sm)->next = (s); } while(0)
#define SM_STACK_SIZE (64*1024)
// State machine init -- state table defined inline as a static local.
// Usage: init_sm(e, INITIAL_STATE, [STATE_A] = fn_a, [STATE_B] = fn_b, ...);
#define init_sm(e, initial, ...) do { \
static void (*_sm_fns[])(CF_Coroutine) = { __VA_ARGS__ }; \
(e)->has_sm = true; \
(e)->sm = (StateMachine){ .state = (initial), .requested = -1, \
.fns = _sm_fns, .num_fns = ARRAY_SIZE(_sm_fns) }; \
} while(0)
// Generic state machine driver. Loops until current state yields.
void tick_state_machine(Entity* e)
{
StateMachine* sm = &e->sm;
if (sm->paused) return;
// Create coroutine on first tick (only allocation for this entity's SM).
if (!sm->co.id) {
sm->co = cf_make_coroutine(sm->fns[sm->state], SM_STACK_SIZE, (void*)e);
if (sm->on_enter) sm->on_enter(e, sm->state, sm->state);
}
int MAX_ITERS = 20;
for (int i = 0; i < MAX_ITERS; i++) {
bool state_dead = cf_coroutine_state(sm->co) == CF_COROUTINE_STATE_DEAD;
bool forced = sm->requested >= 0;
if (forced || state_dead) {
int old = sm->state;
int next = forced ? sm->requested : sm->next;
sm->requested = -1;
if (sm->on_exit) sm->on_exit(e, old, next);
sm->state = next;
if (sm->on_enter) sm->on_enter(e, next, old);
// Reinit coroutine to run new state function (zero alloc).
cf_coroutine_reinit(sm->co, sm->fns[next]);
}
cf_coroutine_resume(sm->co);
if (cf_coroutine_state(sm->co) != CF_COROUTINE_STATE_DEAD) break;
if (i == MAX_ITERS - 1) {
printf("WARNING: tick_state_machine hit 20 transitions in one tick (state %d)\n", sm->state);
}
}
}
//--------------------------------------------------------------------------------------------------
// Example use:
Entity* spawn_player()
{
Entity* e = entity_alloc();
e->kind = ENTITY_PLAYER;
// snip ...
init_sm(e, PLAYER_IDLE,
[PLAYER_IDLE] = ps_idle,
[PLAYER_RUN] = ps_run,
[PLAYER_JUMP] = ps_jump,
[PLAYER_FALLING] = ps_falling,
[PLAYER_WALL_SLIDE] = ps_wall_slide,
[PLAYER_GLIDE] = ps_glide,
[PLAYER_DASH] = ps_dash,
[PLAYER_SLASH] = ps_slash,
);
e->sm.on_exit = player_on_exit_state;
e->sm.on_enter = player_on_enter_state;
return e;
}
// Each state is just a function. yield() pauses until next frame.
// Local variables survive across yields. return exits the state.
void coro ps_dash(CF_Coroutine co)
{
Entity* e = (Entity*)co_udata(co);
Player* player = &e->player;
Mover* m = &e->mover;
StateMachine* sm = &e->sm;
player->dashing = true;
play(&e->sprite, "run");
while (player->dash_timer > 0) {
if (hit_wall(e, player->facing)) {
sm_set(sm, PLAYER_WALL_SLIDE);
return; // exits state, driver runs on_exit/on_enter, reinits coroutine
}
yield(); // pauses here, resumes next frame
}
sm_set(sm, m->on_ground ? PLAYER_IDLE : PLAYER_FALLING);
// return is implicit -- coroutine dies, driver handles transition
}
// Two transition paths:
// From inside a state: sm_set(sm, NEXT_STATE); return;
// From outside: sm_request(&e->sm, NEXT_STATE);
//
// External requests interrupt the running state on next tick --
// the driver reinits the coroutine (abandoning the current call stack)
// and starts the new state fresh. Zero allocation either way.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment