The basics of drawing are really not much different than with 3D. You need a set of vertices, a transformation matrix, and material properties. For a sprite-based game, the vertices will always be a simple quad, and the material will usually just be a texture (though there are often other properties, e.g. to make sprites blink different colors when an enemy takes damage, or the like).
In this case, you can create a single VBO for all sprites, and reuse it.
If you have complex shapes, you're going to want to use a batching approach. Push all your pre-transformed vertices into a single large VBO and render them all in one go. Doing a separate GL draw call for each object is slow.
The transformation matrix at its simplest is a 3x3 matrix representing the homogeneous transformation. You have your translate (location), rotation, and scale. The matrix is composed by concatenating (multiplying) the matrices together. GL prefers column-major column matrices (but you are free to use any other representation, if you wish, but I won't get into how).
Your 2D transformation then is the combination of the Rotation, Scale, and Translation matrices:
|1, 0, tx| |cos(theta), -sin(theta), 0| |sx, 0, 0|
|0, 1, ty| * |sin(theta), cos(theta), 9| * |0, sy, 0|
|0, 0, 1 | |0, 0, 1| |0, 0, 1|
The matrix can either be applied to your vertices on the CPU before uploading, if you're pushing all your vertices into a single VBO.
If you're using raw sprites, instancing can be a lot faster. In this case, you will use a single draw call to render a number of copies of your quad VBO. To access the matrices, they must be stored in either a texture or what's called a Texture Buffer Object. The latter is better. The idea there is that you upload all your transformation matrices for your objects into a VBO, which is bound to a Texture, which is then bound to the shader. The vertex shader uses the special variable gl_InstanceID and the texelFetch() command to read the matrices out of the Buffer Texture, and then you apply it to the vertices.
You can read more about how to use Buffer Texture Objects here:
http://www.opengl.org/wiki/Buffer_Texture
You can read more about instancing here:
http://www.opengl.org/wiki/Vertex_Specification#Instancing
With the instancing approach, you also want to pack in the UV coordinates, and you'll need to use a texture atlas for rendering. I recommend using 2D texture arrays for your atlases, assuming all your sprites have the same width and height. It has a lot of advantages over a traditional texture atlas. The traditional kind works just fine in many cases, if that's what you'd prefer.
The end result is that you'll only have a single draw call for every "material" (e.g. texture), which with atlasing might well be hundreds or thousands of individual objects.
A rough pseudo-code overview then:
init():
vbo_quad = createVBO()
pushQuadVerts(vbo_quad)
tex_atlas = createTextureAtlas()
vbo_objects = createVBO()
tex_objects = createTextureBuffer(vbo_objects)
shader = loadShaders("vertex.glsl", "fragment.glsl")
update():
for each object:
calculateObjectTransform()
pushTransformOntoVBO(vbo_objects)
draw():
bindVBO(vbo_quad)
bindTexture(UNIT0, tex_atlas)
bindTexture(UNIT1, tex_objects)
drawArraysInstanced(GL_QUADS, 0, 4, number_of_objects)
As a final hint, if you're going the instanced route, you might find it a bit quicker and easier to avoid calculating the entire transformation matrix on the CPU, and instead calculate it on the GPU. You can pass in the rotation, scale, and translation. If you do the math, you can take those inputs and calculate the matrix directly. Then instead of needing to eat up 9 floats in memory bandwidth to the GPU and doing a lot of work on the serial CPU, you can pass in 5 floats (4 if you only have uniform scale) and do the work on the parallel GPU.
The math comes out something like this (off the top of my head, so double check):
|sx * cos(theta), -sy * sin(theta), tx|
|sx * sin(theta), sy * cos(theta), ty|
|0, 0, 1 |
Nice and easy.