seamoss
seamoss•4d ago

Using an entity component system for an

Using an entity component system for an application I'm working on and did something really fun with convex recently. I was able to generate unique jotai atoms for each instance of a component by entity key derived from Convex generated types. So like.. I don't have to create a dedicated atom for each component and convex really is my source of truth now. Pretty nice. (code in thread for reference)
5 Replies
seamoss
seamossOP•4d ago
//Tables excluded from state
const excludedTables = new Set(['tenants'])
type ComponentKeys = Exclude<TableNames, 'tenants'>

//This index stores all our entities related to each component
type IndexByComponent = Record<ComponentKeys, ReturnType<typeof atom<Set<Entity>>>>

//This is a map of components by entity key
//Effectively if we, for example, want the position of a known entity we'd
//look up the position component then the entity id
type IndexByEntity = {
[K in ComponentKeys]: ReturnType<typeof atomFamily<Entity, ReturnType<typeof atom<Doc<K>>>>>
}

//Generates a unique Set for each entity across the component dimension
const populateComponentIndex = (index: IndexByComponent) => {
Object.keys(schema.tables).forEach((tableName) => {
if (excludedTables.has(tableName)) return

index[tableName as ComponentKeys] = atom<Set<Entity>>(new Set<Entity>())
})
return index
}

//Helper function for populating the entity index
//Atom families store a unique value based on the key (in this case, entity id)
function createAtomFamily<K extends ComponentKeys>(_tableName: K) {
return atomFamily((_entity: Entity) => atom<Doc<K>>())
}
//Populations the entity index based on the component key
//Each key gets an atom family as a value, which is in itself a map of entities to the
//associated `Doc` type generated by convex
const populateEntityIndex = (index: IndexByEntity) => {
Object.keys(schema.tables).forEach((tableName) => {
if (excludedTables.has(tableName)) return

const componentKey = tableName as ComponentKeys
;(index as any)[componentKey] = createAtomFamily(componentKey)
})
return index
}

export const indexByComponentAtom = atom<IndexByComponent>(populateComponentIndex({} as IndexByComponent))
export const indexByEntityAtom = atom<IndexByEntity>(populateEntityIndex({} as IndexByEntity))
//Tables excluded from state
const excludedTables = new Set(['tenants'])
type ComponentKeys = Exclude<TableNames, 'tenants'>

//This index stores all our entities related to each component
type IndexByComponent = Record<ComponentKeys, ReturnType<typeof atom<Set<Entity>>>>

//This is a map of components by entity key
//Effectively if we, for example, want the position of a known entity we'd
//look up the position component then the entity id
type IndexByEntity = {
[K in ComponentKeys]: ReturnType<typeof atomFamily<Entity, ReturnType<typeof atom<Doc<K>>>>>
}

//Generates a unique Set for each entity across the component dimension
const populateComponentIndex = (index: IndexByComponent) => {
Object.keys(schema.tables).forEach((tableName) => {
if (excludedTables.has(tableName)) return

index[tableName as ComponentKeys] = atom<Set<Entity>>(new Set<Entity>())
})
return index
}

//Helper function for populating the entity index
//Atom families store a unique value based on the key (in this case, entity id)
function createAtomFamily<K extends ComponentKeys>(_tableName: K) {
return atomFamily((_entity: Entity) => atom<Doc<K>>())
}
//Populations the entity index based on the component key
//Each key gets an atom family as a value, which is in itself a map of entities to the
//associated `Doc` type generated by convex
const populateEntityIndex = (index: IndexByEntity) => {
Object.keys(schema.tables).forEach((tableName) => {
if (excludedTables.has(tableName)) return

const componentKey = tableName as ComponentKeys
;(index as any)[componentKey] = createAtomFamily(componentKey)
})
return index
}

export const indexByComponentAtom = atom<IndexByComponent>(populateComponentIndex({} as IndexByComponent))
export const indexByEntityAtom = atom<IndexByEntity>(populateEntityIndex({} as IndexByEntity))
Really niche use case, but I think the lightbulb in my head went off on how awesome convex is once I figured this out. Hard to overstate how much boilerplate this has saved me On the convex side, I can update component data with something like:
// Define tables to exclude from components (single source of truth)
const excludedTables = ['tenants'] as const
type ExcludedTable = (typeof excludedTables)[number]

type ComponentKeys = Exclude<TableNames, ExcludedTable>

// Create array of component keys excluding specified tables
const componentKeys = Object.keys(schema.tables).filter(
(key): key is ComponentKeys => !excludedTables.includes(key as ExcludedTable)
) as readonly ComponentKeys[]

export const updateComponent = mutation({
args: {
component: v.union(...componentKeys.map(v.literal)),
data: v.any(),
},
handler: async (ctx, args) => {
console.log('mutator', args)
if (args.data['_id']) {
const existing = await ctx.db.get(args.data['_id'])
await ctx.db.patch(args.data['_id'], { ...args.data })
} else {
await ctx.db.insert(args.component, { ...args.data })
}
},
})
// Define tables to exclude from components (single source of truth)
const excludedTables = ['tenants'] as const
type ExcludedTable = (typeof excludedTables)[number]

type ComponentKeys = Exclude<TableNames, ExcludedTable>

// Create array of component keys excluding specified tables
const componentKeys = Object.keys(schema.tables).filter(
(key): key is ComponentKeys => !excludedTables.includes(key as ExcludedTable)
) as readonly ComponentKeys[]

export const updateComponent = mutation({
args: {
component: v.union(...componentKeys.map(v.literal)),
data: v.any(),
},
handler: async (ctx, args) => {
console.log('mutator', args)
if (args.data['_id']) {
const existing = await ctx.db.get(args.data['_id'])
await ctx.db.patch(args.data['_id'], { ...args.data })
} else {
await ctx.db.insert(args.component, { ...args.data })
}
},
})
Which is nice since I have hundreds of tables representing various components and the producer of the data can't be opinionated on how that data is consumed. don't mind me, over here getting excited over a simple factory pattern 🙃
jamwt
jamwt•4d ago
I love ECS such an elegant design for something like an actor system/game system etc
seamoss
seamossOP•4d ago
We’re building a CAD tool for drug discovery. A lot of the principles of game development actually apply pretty well to CAD tools haha
deen
deen•4d ago
Jotai is a great companion for Convex. I've dreamed of building a full integration, looks like you're on the way there. I find that ECS is usually discussed in terms of performance, but I find the organisational pattern so useful, and it maps pretty well to the web, React, the flux pattern, etc.
seamoss
seamossOP•3d ago
Oh 1000%! Convex and jotai fit like a glove, I was pleasantly surprised. Once I have everything together I'll probably write a devblog about it. In applications where the consumer of the data is more opinionated than the producer of the data (think: games, whiteboarding tools, CAD, even big data ETL pipelines) ECS is a phenomenal pattern. I think most people have object oriented design hammered into their heads in university so it's a bit counter intuitive when you first start using it, but it's a fantastic tool in the toolbelt when you find your data models getting extremely brittle / bloated

Did you find this page helpful?